Add CCP Metrics page with multi-vertical VCL upload and cross-org reporting
New feature: multi-file per-vertical compliance xlsx upload with scoped resolution logic, executive-level aggregated reporting, and drill-down by vertical and metric. Supports daily upload cadence and batch commit. Backend: - Migration: add vertical column to compliance_items/uploads, create vcl_multi_vertical_summary table - New route module: routes/vclMultiVertical.js with preview, commit, stats, trend, metric drill-down, device list, and burndown endpoints - New helpers: parseVerticalFilename(), computeVerticalBurndown() - Vertical-scoped resolution: uploading one vertical never resolves items from other verticals Frontend: - CCPMetricsPage with stats bar, trend chart, donut, vertical table - Drill-down: vertical -> metrics by category -> device list - Per-vertical burndown forecast chart - MultiVerticalUploadModal: multi-file drag-drop, batch preview, commit - Nav entry: CCP Metrics (Building2 icon) Docs: - Design brief for stakeholder meeting (docs/vcl-multi-vertical-design-brief.md)
This commit is contained in:
@@ -205,6 +205,79 @@ function mapColumnHeaders(headers) {
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts vertical code and report date from a filename.
|
||||
* Pattern: <VERTICAL>_YYYY_MM_DD.xlsx
|
||||
* The vertical is everything before the trailing _YYYY_MM_DD portion.
|
||||
*
|
||||
* Examples:
|
||||
* NTS_AEO_2026_05_11.xlsx → { vertical: 'NTS_AEO', date: '2026-05-11' }
|
||||
* SDIT_CISO_2026_05_11.xlsx → { vertical: 'SDIT_CISO', date: '2026-05-11' }
|
||||
* SR_2026_05_11.xlsx → { vertical: 'SR', date: '2026-05-11' }
|
||||
* AllOthers_2026_05_11.xlsx → { vertical: 'AllOthers', date: '2026-05-11' }
|
||||
*
|
||||
* Returns null if the filename does not match the expected pattern.
|
||||
*/
|
||||
function parseVerticalFilename(filename) {
|
||||
// Strip .xlsx extension (case-insensitive)
|
||||
const stem = filename.replace(/\.xlsx$/i, '');
|
||||
// Match: everything up to the last _YYYY_MM_DD
|
||||
const match = stem.match(/^(.+?)_(\d{4})_(\d{2})_(\d{2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const vertical = match[1];
|
||||
const date = `${match[2]}-${match[3]}-${match[4]}`;
|
||||
|
||||
// Validate the date portion is a real date
|
||||
if (!isValidDateString(date)) return null;
|
||||
|
||||
return { vertical, date };
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes per-vertical burndown forecast from non-compliant items.
|
||||
* Returns breakdown of items with/without resolution dates and monthly projections.
|
||||
*/
|
||||
function computeVerticalBurndown(items) {
|
||||
const total = items.length;
|
||||
const withDates = items.filter(i => i.resolution_date != null);
|
||||
const blockers = items.filter(i => i.resolution_date == null);
|
||||
|
||||
// Bucket by month
|
||||
const monthly = {};
|
||||
for (const item of withDates) {
|
||||
const dateStr = typeof item.resolution_date === 'string'
|
||||
? item.resolution_date
|
||||
: item.resolution_date.toISOString().slice(0, 10);
|
||||
const month = dateStr.slice(0, 7); // YYYY-MM
|
||||
monthly[month] = (monthly[month] || 0) + 1;
|
||||
}
|
||||
|
||||
// Cumulative projection — how many remain after each month
|
||||
let remaining = total;
|
||||
const projection = {};
|
||||
for (const month of Object.keys(monthly).sort()) {
|
||||
remaining -= monthly[month];
|
||||
projection[month] = { remediated: monthly[month], remaining };
|
||||
}
|
||||
|
||||
// Projected clear date — first month where remaining hits 0 (excluding blockers)
|
||||
let projectedClearDate = null;
|
||||
if (blockers.length === 0 && Object.keys(projection).length > 0) {
|
||||
const sortedMonths = Object.keys(projection).sort();
|
||||
projectedClearDate = sortedMonths[sortedMonths.length - 1];
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
blockers: blockers.length,
|
||||
with_dates: withDates.length,
|
||||
monthly,
|
||||
projection,
|
||||
projected_clear_date: projectedClearDate,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
truncateText,
|
||||
validateRemediationPlan,
|
||||
@@ -217,4 +290,6 @@ module.exports = {
|
||||
matchByHostname,
|
||||
computeBulkDiff,
|
||||
mapColumnHeaders,
|
||||
parseVerticalFilename,
|
||||
computeVerticalBurndown,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user