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:
Jordan Ramos
2026-05-14 09:49:59 -06:00
parent d61383ac7b
commit 04360cc4bc
10 changed files with 2243 additions and 1 deletions

View File

@@ -14,6 +14,7 @@ import VulnerabilityTriagePage from './components/pages/ReportingPage';
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
import ExportsPage from './components/pages/ExportsPage';
import CompliancePage from './components/pages/CompliancePage';
import CCPMetricsPage from './components/pages/CCPMetricsPage';
import JiraPage from './components/pages/JiraPage';
import AdminPage from './components/pages/AdminPage';
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
@@ -1082,6 +1083,7 @@ export default function App() {
{/* Page content */}
{currentPage === 'triage' && <VulnerabilityTriagePage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
{currentPage === 'ccp-metrics' && <CCPMetricsPage />}
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
{currentPage === 'exports' && <ExportsPage />}
{currentPage === 'jira' && <JiraPage />}

View File

@@ -1,11 +1,12 @@
import React from 'react';
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket } from 'lucide-react';
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2 } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const NAV_ITEMS = [
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
{ id: 'ccp-metrics', label: 'CCP Metrics', icon: Building2, color: '#A78BFA', description: 'Cross-vertical VCL reporting' },
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
{ id: 'jira', label: 'Jira Tickets', icon: Ticket, color: '#6366F1', description: 'Jira issue tracking & sync' },

View File

@@ -0,0 +1,602 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Upload, Building2, ChevronLeft, Loader, AlertCircle, TrendingUp, Target, ShieldAlert, BarChart3 } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { PieChart, Pie, Cell, ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts';
import MultiVerticalUploadModal from './MultiVerticalUploadModal';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6';
const PURPLE = '#A78BFA';
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const PAGE_STYLE = {
padding: '1.5rem 2rem',
minHeight: '100vh',
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
};
const CARD_STYLE = {
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(167, 139, 250, 0.2)',
borderRadius: '0.75rem',
padding: '1.25rem',
};
const STAT_CARD_STYLE = {
...CARD_STYLE,
textAlign: 'center',
flex: 1,
minWidth: '140px',
};
const TABLE_STYLE = {
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.8rem',
};
const TH_STYLE = {
padding: '0.75rem 1rem',
textAlign: 'left',
color: '#94A3B8',
fontWeight: '600',
fontSize: '0.7rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
borderBottom: '1px solid rgba(255,255,255,0.08)',
};
const TD_STYLE = {
padding: '0.75rem 1rem',
color: '#E2E8F0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
};
// ---------------------------------------------------------------------------
// Stats Bar
// ---------------------------------------------------------------------------
function StatsBar({ stats }) {
if (!stats) return null;
const items = [
{ label: 'Total Devices', value: stats.total_devices.toLocaleString(), color: '#94A3B8' },
{ label: 'Compliant', value: stats.compliant.toLocaleString(), color: '#10B981' },
{ label: 'Non-Compliant', value: stats.non_compliant.toLocaleString(), color: '#EF4444' },
{ label: 'Current %', value: `${stats.compliance_pct}%`, color: stats.compliance_pct >= stats.target_pct ? '#10B981' : '#F59E0B' },
{ label: 'Target %', value: `${stats.target_pct}%`, color: PURPLE },
];
return (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
{items.map(({ label, value, color }) => (
<div key={label} style={STAT_CARD_STYLE}>
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.5rem' }}>{label}</div>
<div style={{ fontSize: '1.5rem', fontWeight: '700', color }}>{value}</div>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Donut Chart
// ---------------------------------------------------------------------------
function DonutChart({ donut }) {
if (!donut) return null;
const data = [
{ name: 'Blocked', count: donut.blocked.count, color: '#EF4444' },
{ name: 'In-Progress', count: donut.in_progress.count, color: '#F59E0B' },
];
const total = donut.blocked.count + donut.in_progress.count;
return (
<div style={{ ...CARD_STYLE, flex: 1 }}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
Non-Compliant Status
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ResponsiveContainer width={200} height={200}>
<PieChart>
<Pie data={data} innerRadius={55} outerRadius={80} dataKey="count" nameKey="name">
{data.map((entry, i) => <Cell key={i} fill={entry.color} />)}
</Pie>
<Tooltip contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '0.5rem', fontSize: '0.75rem' }} />
<Legend wrapperStyle={{ fontSize: '0.7rem' }} />
</PieChart>
</ResponsiveContainer>
<div style={{ textAlign: 'center', marginLeft: '1rem' }}>
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#E2E8F0' }}>{total}</div>
<div style={{ fontSize: '0.65rem', color: '#64748B' }}>Total Non-Compliant</div>
<div style={{ marginTop: '0.75rem', fontSize: '0.7rem' }}>
<div style={{ color: '#EF4444' }}>Blocked: {donut.blocked.count} ({donut.blocked.pct}%)</div>
<div style={{ color: '#F59E0B' }}>In-Progress: {donut.in_progress.count} ({donut.in_progress.pct}%)</div>
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Trend Chart
// ---------------------------------------------------------------------------
function TrendChart({ months }) {
if (!months || months.length === 0) return (
<div style={{ ...CARD_STYLE, flex: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748B', fontSize: '0.8rem' }}>
No trend data yet. Upload compliance data to generate trends.
</div>
);
return (
<div style={{ ...CARD_STYLE, flex: 2 }}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
Compliance Trend
</div>
<ResponsiveContainer width="100%" height={220}>
<ComposedChart data={months}>
<CartesianGrid stroke="rgba(255,255,255,0.05)" strokeDasharray="3 3" />
<XAxis dataKey="month" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis yAxisId="count" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis yAxisId="pct" orientation="right" domain={[0, 100]} unit="%" tick={{ fontSize: 10, fill: '#64748B' }} />
<Tooltip contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '0.5rem', fontSize: '0.75rem' }} />
<Bar yAxisId="count" dataKey="compliant_count" fill="#10B981" fillOpacity={0.6} name="Compliant Devices" />
<Line yAxisId="pct" dataKey="compliance_pct" stroke={TEAL} strokeWidth={2} dot={{ r: 3 }} name="Actual %" />
<Line yAxisId="pct" dataKey="forecast_pct" stroke={TEAL} strokeWidth={2} strokeDasharray="5 3" dot={false} name="Forecast %" />
<ReferenceLine yAxisId="pct" y={months[0]?.target_pct || 95} stroke="#F59E0B" strokeDasharray="4 4" label={{ value: 'Target', fill: '#F59E0B', fontSize: 10 }} />
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
// ---------------------------------------------------------------------------
// Vertical Breakdown Table
// ---------------------------------------------------------------------------
function VerticalTable({ breakdown, onSelectVertical }) {
if (!breakdown || breakdown.length === 0) return null;
return (
<div style={{ ...CARD_STYLE, marginTop: '1.5rem' }}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
Vertical Breakdown
</div>
<div style={{ overflowX: 'auto' }}>
<table style={TABLE_STYLE}>
<thead>
<tr>
<th style={TH_STYLE}>Vertical</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Total</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Compliant</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Non-Compliant</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Compliance %</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Blockers</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Last Upload</th>
</tr>
</thead>
<tbody>
{breakdown.map(v => {
const pctColor = v.compliance_pct >= 95 ? '#10B981' : v.compliance_pct >= 80 ? '#F59E0B' : '#EF4444';
return (
<tr
key={v.vertical}
onClick={() => onSelectVertical(v.vertical)}
style={{ cursor: 'pointer', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(167, 139, 250, 0.08)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<td style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}>{v.vertical}</td>
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{v.total_devices.toLocaleString()}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{v.compliant.toLocaleString()}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{v.non_compliant.toLocaleString()}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: pctColor }}>{v.compliance_pct}%</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: v.blockers > 0 ? '#EF4444' : '#64748B' }}>{v.blockers}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#64748B', fontSize: '0.7rem' }}>{v.last_upload || '—'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Vertical Detail View (metric drill-down)
// ---------------------------------------------------------------------------
function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
const [metrics, setMetrics] = useState(null);
const [categories, setCategories] = useState(null);
const [burndown, setBurndown] = useState(null);
const [loading, setLoading] = useState(true);
// ⚠️ CONVENTION: No error state — catch silently swallows errors without displaying them to the user
useEffect(() => {
setLoading(true);
Promise.all([
fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}/metrics`, { credentials: 'include' }).then(r => r.json()),
fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}/burndown`, { credentials: 'include' }).then(r => r.json()),
]).then(([metricsData, burndownData]) => {
setMetrics(metricsData.metrics || []);
setCategories(metricsData.categories || []);
setBurndown(burndownData);
setLoading(false);
}).catch(() => setLoading(false));
}, [vertical]);
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
return (
<div>
<button
onClick={onBack}
style={{ background: 'none', border: 'none', color: PURPLE, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.8rem', marginBottom: '1.5rem', padding: 0 }}
>
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Overview
</button>
<h2 style={{ fontSize: '1.2rem', color: '#E2E8F0', marginBottom: '1rem', fontWeight: '700' }}>
<Building2 style={{ width: '20px', height: '20px', display: 'inline', marginRight: '0.5rem', color: PURPLE }} />
{vertical}
</h2>
{/* Burndown summary */}
{burndown && (
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<div style={STAT_CARD_STYLE}>
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Non-Compliant</div>
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#EF4444' }}>{burndown.total}</div>
</div>
<div style={STAT_CARD_STYLE}>
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>With Dates</div>
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#F59E0B' }}>{burndown.with_dates}</div>
</div>
<div style={STAT_CARD_STYLE}>
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Blockers</div>
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#EF4444' }}>{burndown.blockers}</div>
</div>
{burndown.projected_clear_date && (
<div style={STAT_CARD_STYLE}>
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Projected Clear</div>
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#10B981' }}>{burndown.projected_clear_date}</div>
</div>
)}
</div>
)}
{/* Burndown chart */}
{burndown && burndown.monthly && Object.keys(burndown.monthly).length > 0 && (
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem' }}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
Burndown Forecast
</div>
<ResponsiveContainer width="100%" height={180}>
<ComposedChart data={Object.entries(burndown.monthly).sort(([a], [b]) => a.localeCompare(b)).map(([month, count]) => ({ month, count }))}>
<CartesianGrid stroke="rgba(255,255,255,0.05)" strokeDasharray="3 3" />
<XAxis dataKey="month" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis tick={{ fontSize: 10, fill: '#64748B' }} />
<Tooltip contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '0.5rem', fontSize: '0.75rem' }} />
<Bar dataKey="count" fill={PURPLE} fillOpacity={0.7} name="Remediations" />
</ComposedChart>
</ResponsiveContainer>
</div>
)}
{/* Category summary */}
{categories && categories.length > 0 && (
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem' }}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
By Category
</div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
{categories.map(c => (
<div key={c.category} style={{ ...CARD_STYLE, padding: '0.75rem 1rem', minWidth: '160px' }}>
<div style={{ fontSize: '0.65rem', color: '#94A3B8', marginBottom: '0.25rem' }}>{c.category}</div>
<div style={{ fontSize: '1.1rem', fontWeight: '700', color: c.compliance_pct >= 95 ? '#10B981' : c.compliance_pct >= 80 ? '#F59E0B' : '#EF4444' }}>
{c.compliance_pct}%
</div>
<div style={{ fontSize: '0.6rem', color: '#64748B' }}>{c.non_compliant} non-compliant</div>
</div>
))}
</div>
</div>
)}
{/* Metrics table */}
<div style={CARD_STYLE}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
Metrics
</div>
<table style={TABLE_STYLE}>
<thead>
<tr>
<th style={TH_STYLE}>Metric</th>
<th style={TH_STYLE}>Description</th>
<th style={TH_STYLE}>Category</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Compliant</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Non-Compliant</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Total</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>%</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Target</th>
</tr>
</thead>
<tbody>
{metrics && metrics.map((m, i) => {
const pctColor = m.compliance_pct >= m.target ? '#10B981' : m.compliance_pct >= (m.target * 0.85) ? '#F59E0B' : '#EF4444';
return (
<tr
key={i}
onClick={() => onSelectMetric(m.metric_id)}
style={{ cursor: 'pointer', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(167, 139, 250, 0.06)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<td style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}>{m.metric_id}</td>
<td style={{ ...TD_STYLE, fontSize: '0.7rem', color: '#94A3B8', maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.metric_desc}</td>
<td style={{ ...TD_STYLE, fontSize: '0.7rem' }}>{m.category}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{m.compliant}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{m.non_compliant}</td>
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{m.total}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: pctColor }}>{Number(m.compliance_pct).toFixed(1)}%</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#64748B' }}>{Number(m.target).toFixed(0)}%</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Metric Device List (deepest drill-down)
// ---------------------------------------------------------------------------
function MetricDeviceList({ vertical, metricId, onBack }) {
const [devices, setDevices] = useState(null);
const [loading, setLoading] = useState(true);
// ⚠️ CONVENTION: No error state — catch silently swallows errors without displaying them to the user
useEffect(() => {
setLoading(true);
fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}/metric/${encodeURIComponent(metricId)}/devices`, { credentials: 'include' })
.then(r => r.json())
.then(data => { setDevices(data.devices || []); setLoading(false); })
.catch(() => setLoading(false));
}, [vertical, metricId]);
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
return (
<div>
<button
onClick={onBack}
style={{ background: 'none', border: 'none', color: PURPLE, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.8rem', marginBottom: '1.5rem', padding: 0 }}
>
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Metrics
</button>
<h3 style={{ fontSize: '1rem', color: '#E2E8F0', marginBottom: '1rem' }}>
{vertical} / Metric {metricId} {devices ? devices.length : 0} non-compliant devices
</h3>
<div style={CARD_STYLE}>
<table style={TABLE_STYLE}>
<thead>
<tr>
<th style={TH_STYLE}>Hostname</th>
<th style={TH_STYLE}>IP Address</th>
<th style={TH_STYLE}>Type</th>
<th style={TH_STYLE}>Team</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Seen Count</th>
<th style={TH_STYLE}>First Seen</th>
<th style={TH_STYLE}>Last Seen</th>
<th style={TH_STYLE}>Resolution Date</th>
<th style={TH_STYLE}>Remediation Plan</th>
</tr>
</thead>
<tbody>
{devices && devices.map((d, i) => (
<tr key={i}>
<td style={{ ...TD_STYLE, fontWeight: '600', color: '#E2E8F0' }}>{d.hostname}</td>
<td style={{ ...TD_STYLE, color: '#94A3B8' }}>{d.ip_address || '—'}</td>
<td style={{ ...TD_STYLE, color: '#94A3B8' }}>{d.device_type || '—'}</td>
<td style={{ ...TD_STYLE, color: '#94A3B8' }}>{d.team || '—'}</td>
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{d.seen_count}</td>
<td style={{ ...TD_STYLE, color: '#64748B', fontSize: '0.7rem' }}>{d.first_seen || '—'}</td>
<td style={{ ...TD_STYLE, color: '#64748B', fontSize: '0.7rem' }}>{d.last_seen || '—'}</td>
<td style={{ ...TD_STYLE, color: d.resolution_date ? '#F59E0B' : '#64748B', fontSize: '0.7rem' }}>{d.resolution_date || 'Not set'}</td>
<td style={{ ...TD_STYLE, color: '#94A3B8', fontSize: '0.7rem', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.remediation_plan || '—'}</td>
</tr>
))}
{devices && devices.length === 0 && (
<tr><td colSpan={9} style={{ ...TD_STYLE, textAlign: 'center', color: '#64748B' }}>No devices found</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main Page Component
// ---------------------------------------------------------------------------
export default function CCPMetricsPage() {
const { isAdmin, isEditor } = useAuth();
const [stats, setStats] = useState(null);
const [trend, setTrend] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showUpload, setShowUpload] = useState(false);
// Drill-down state
const [selectedVertical, setSelectedVertical] = useState(null);
const [selectedMetric, setSelectedMetric] = useState(null);
const fetchData = useCallback(() => {
setLoading(true);
setError(null);
Promise.all([
fetch(`${API_BASE}/compliance/vcl-multi/stats`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load stats'); return r.json(); }),
fetch(`${API_BASE}/compliance/vcl-multi/trend`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load trend'); return r.json(); }),
]).then(([statsData, trendData]) => {
setStats(statsData);
setTrend(trendData);
setLoading(false);
}).catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const handleUploadComplete = () => {
setShowUpload(false);
fetchData();
};
// Render drill-down views
if (selectedMetric && selectedVertical) {
return (
<div style={PAGE_STYLE}>
<MetricDeviceList
vertical={selectedVertical}
metricId={selectedMetric}
onBack={() => setSelectedMetric(null)}
/>
</div>
);
}
if (selectedVertical) {
return (
<div style={PAGE_STYLE}>
<VerticalDetailView
vertical={selectedVertical}
onBack={() => setSelectedVertical(null)}
onSelectMetric={(metricId) => setSelectedMetric(metricId)}
/>
</div>
);
}
// Main overview
return (
<div style={PAGE_STYLE}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<div>
<h1 style={{ fontSize: '1.3rem', fontWeight: '700', color: '#E2E8F0', margin: 0, display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<BarChart3 style={{ width: '24px', height: '24px', color: PURPLE }} />
CCP Metrics Multi-Vertical VCL
</h1>
<p style={{ fontSize: '0.7rem', color: '#64748B', margin: '0.25rem 0 0 0' }}>
Cross-organizational compliance posture across all verticals
</p>
</div>
{(isAdmin() || isEditor()) && (
<button
onClick={() => setShowUpload(true)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.6rem 1.2rem',
background: `${PURPLE}20`,
border: `1px solid ${PURPLE}60`,
borderRadius: '0.5rem',
color: PURPLE,
fontSize: '0.75rem',
fontWeight: '600',
cursor: 'pointer',
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = `${PURPLE}35`}
onMouseLeave={e => e.currentTarget.style.background = `${PURPLE}20`}
>
<Upload style={{ width: '14px', height: '14px' }} />
Upload Verticals
</button>
)}
</div>
{/* Loading / Error states */}
{loading && (
<div style={{ textAlign: 'center', padding: '3rem', color: '#64748B' }}>
<Loader style={{ width: '24px', height: '24px', animation: 'spin 1s linear infinite', marginBottom: '0.5rem' }} />
<div style={{ fontSize: '0.8rem' }}>Loading compliance data...</div>
</div>
)}
{error && (
<div style={{ ...CARD_STYLE, borderColor: 'rgba(239, 68, 68, 0.3)', display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '1rem' }}>
<AlertCircle style={{ width: '18px', height: '18px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ color: '#EF4444', fontSize: '0.8rem' }}>{error}</span>
</div>
)}
{!loading && !error && stats && (
<>
{/* Stats bar */}
<StatsBar stats={stats.stats} />
{/* Charts row */}
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<TrendChart months={trend?.months} />
<DonutChart donut={stats.donut} />
</div>
{/* Vertical breakdown table */}
<VerticalTable
breakdown={stats.vertical_breakdown}
onSelectVertical={setSelectedVertical}
/>
{/* Last upload info */}
{stats.last_upload_date && (
<div style={{ marginTop: '1rem', fontSize: '0.65rem', color: '#475569', textAlign: 'right' }}>
Last upload: {stats.last_upload_date}
</div>
)}
</>
)}
{!loading && !error && (!stats || !stats.vertical_breakdown || stats.vertical_breakdown.length === 0) && (
<div style={{ ...CARD_STYLE, textAlign: 'center', padding: '3rem', marginTop: '2rem' }}>
<Building2 style={{ width: '48px', height: '48px', color: '#334155', margin: '0 auto 1rem' }} />
<div style={{ fontSize: '1rem', color: '#94A3B8', marginBottom: '0.5rem' }}>No multi-vertical data yet</div>
<div style={{ fontSize: '0.75rem', color: '#64748B', marginBottom: '1.5rem' }}>
Upload per-vertical compliance xlsx files to generate cross-organizational reports.
</div>
{(isAdmin() || isEditor()) && (
<button
onClick={() => setShowUpload(true)}
style={{
padding: '0.6rem 1.5rem',
background: PURPLE,
border: 'none',
borderRadius: '0.5rem',
color: '#FFF',
fontSize: '0.8rem',
fontWeight: '600',
cursor: 'pointer',
}}
>
Upload Verticals
</button>
)}
</div>
)}
{/* Upload Modal */}
{showUpload && (
<MultiVerticalUploadModal
onClose={() => setShowUpload(false)}
onUploadComplete={handleUploadComplete}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,428 @@
import React, { useState, useRef } from 'react';
import { X, Upload, FileSpreadsheet, Loader, CheckCircle, AlertCircle, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const PURPLE = '#A78BFA';
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const OVERLAY_STYLE = {
position: 'fixed', inset: 0,
background: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(4px)',
zIndex: 100,
display: 'flex', alignItems: 'center', justifyContent: 'center',
};
const MODAL_STYLE = {
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: `1px solid ${PURPLE}40`,
borderRadius: '1rem',
width: '90%',
maxWidth: '900px',
maxHeight: '85vh',
overflow: 'auto',
padding: '2rem',
boxShadow: `0 0 60px rgba(167, 139, 250, 0.15)`,
};
const DROP_ZONE_STYLE = {
border: `2px dashed ${PURPLE}50`,
borderRadius: '0.75rem',
padding: '3rem 2rem',
textAlign: 'center',
cursor: 'pointer',
transition: 'border-color 0.2s, background 0.2s',
};
const DROP_ZONE_ACTIVE = {
...DROP_ZONE_STYLE,
borderColor: PURPLE,
background: `${PURPLE}10`,
};
const FILE_ROW_STYLE = {
display: 'flex',
alignItems: 'center',
gap: '1rem',
padding: '0.75rem 1rem',
borderRadius: '0.5rem',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(255,255,255,0.06)',
marginBottom: '0.5rem',
};
// phase: idle → uploading → preview → committing → done | error
export default function MultiVerticalUploadModal({ onClose, onUploadComplete }) {
const [phase, setPhase] = useState('idle');
const [files, setFiles] = useState([]);
const [previewData, setPreviewData] = useState(null);
const [error, setError] = useState(null);
const [dragOver, setDragOver] = useState(false);
const [commitResult, setCommitResult] = useState(null);
const fileInputRef = useRef(null);
// Handle file selection
const handleFiles = (fileList) => {
const newFiles = Array.from(fileList).filter(f => f.name.toLowerCase().endsWith('.xlsx'));
if (newFiles.length === 0) {
setError('Please select .xlsx files');
return;
}
setFiles(prev => {
const existing = new Set(prev.map(f => f.name));
const unique = newFiles.filter(f => !existing.has(f.name));
return [...prev, ...unique];
});
setError(null);
};
const removeFile = (index) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
// Upload and preview
const handlePreview = async () => {
if (files.length === 0) return;
setPhase('uploading');
setError(null);
const formData = new FormData();
for (const file of files) {
formData.append('files', file);
}
try {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/preview`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Upload failed');
setPhase('idle');
return;
}
setPreviewData(data);
setPhase('preview');
} catch (err) {
setError('Network error: ' + err.message);
setPhase('idle');
}
};
// Commit
const handleCommit = async () => {
if (!previewData || !previewData.files || previewData.files.length === 0) return;
setPhase('committing');
setError(null);
try {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/commit`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
files: previewData.files.map(f => ({
tempFile: f.tempFile,
vertical: f.vertical,
report_date: f.report_date,
filename: f.filename,
})),
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Commit failed');
setPhase('preview');
return;
}
setCommitResult(data);
setPhase('done');
} catch (err) {
setError('Network error: ' + err.message);
setPhase('preview');
}
};
// Remove a file from preview
const removePreviewFile = (index) => {
setPreviewData(prev => ({
...prev,
files: prev.files.filter((_, i) => i !== index),
}));
};
return (
<div style={OVERLAY_STYLE} onClick={onClose}>
<div style={MODAL_STYLE} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1.1rem', fontWeight: '700', color: '#E2E8F0', margin: 0 }}>
Upload Vertical Files
</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.25rem' }}>
<X style={{ width: '20px', height: '20px' }} />
</button>
</div>
{/* Phase: Idle — file selection */}
{phase === 'idle' && (
<>
{/* Drop zone */}
<div
style={dragOver ? DROP_ZONE_ACTIVE : DROP_ZONE_STYLE}
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={e => { e.preventDefault(); setDragOver(false); handleFiles(e.dataTransfer.files); }}
onClick={() => fileInputRef.current?.click()}
>
<Upload style={{ width: '32px', height: '32px', color: PURPLE, margin: '0 auto 0.75rem' }} />
<div style={{ fontSize: '0.85rem', color: '#E2E8F0', marginBottom: '0.25rem' }}>
Drop xlsx files here or click to browse
</div>
<div style={{ fontSize: '0.7rem', color: '#64748B' }}>
Expected format: VERTICAL_YYYY_MM_DD.xlsx (up to 14 files)
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept=".xlsx"
multiple
style={{ display: 'none' }}
onChange={e => handleFiles(e.target.files)}
/>
{/* Selected files list */}
{files.length > 0 && (
<div style={{ marginTop: '1.5rem' }}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.75rem' }}>
Selected Files ({files.length})
</div>
{files.map((file, i) => (
<div key={i} style={FILE_ROW_STYLE}>
<FileSpreadsheet style={{ width: '16px', height: '16px', color: '#10B981', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: '0.8rem', color: '#E2E8F0' }}>{file.name}</span>
<span style={{ fontSize: '0.65rem', color: '#64748B' }}>{(file.size / 1024).toFixed(0)} KB</span>
<button
onClick={() => removeFile(i)}
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.25rem' }}
onMouseEnter={e => e.currentTarget.style.color = '#EF4444'}
onMouseLeave={e => e.currentTarget.style.color = '#64748B'}
>
<Trash2 style={{ width: '14px', height: '14px' }} />
</button>
</div>
))}
<button
onClick={handlePreview}
style={{
marginTop: '1rem',
padding: '0.7rem 2rem',
background: PURPLE,
border: 'none',
borderRadius: '0.5rem',
color: '#FFF',
fontSize: '0.8rem',
fontWeight: '600',
cursor: 'pointer',
width: '100%',
}}
>
Preview Upload
</button>
</div>
)}
</>
)}
{/* Phase: Uploading */}
{phase === 'uploading' && (
<div style={{ textAlign: 'center', padding: '3rem' }}>
<Loader style={{ width: '32px', height: '32px', color: PURPLE, animation: 'spin 1s linear infinite', margin: '0 auto 1rem' }} />
<div style={{ fontSize: '0.85rem', color: '#E2E8F0' }}>Parsing {files.length} file(s)...</div>
<div style={{ fontSize: '0.7rem', color: '#64748B', marginTop: '0.25rem' }}>Extracting verticals and computing diffs</div>
</div>
)}
{/* Phase: Preview */}
{phase === 'preview' && previewData && (
<>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
Preview {previewData.files.length} file(s) ready
</div>
{/* Preview table */}
<div style={{ overflowX: 'auto', marginBottom: '1rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
<thead>
<tr>
<th style={{ padding: '0.5rem', textAlign: 'left', color: '#64748B', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Vertical</th>
<th style={{ padding: '0.5rem', textAlign: 'left', color: '#64748B', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Date</th>
<th style={{ padding: '0.5rem', textAlign: 'right', color: '#64748B', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Items</th>
<th style={{ padding: '0.5rem', textAlign: 'right', color: '#10B981', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>New</th>
<th style={{ padding: '0.5rem', textAlign: 'right', color: '#F59E0B', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Recurring</th>
<th style={{ padding: '0.5rem', textAlign: 'right', color: '#0EA5E9', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Resolved</th>
<th style={{ padding: '0.5rem', textAlign: 'center', borderBottom: '1px solid rgba(255,255,255,0.08)' }}></th>
</tr>
</thead>
<tbody>
{previewData.files.map((f, i) => (
<tr key={i}>
<td style={{ padding: '0.5rem', color: PURPLE, fontWeight: '600' }}>{f.vertical}</td>
<td style={{ padding: '0.5rem', color: '#94A3B8' }}>{f.report_date}</td>
<td style={{ padding: '0.5rem', textAlign: 'right', color: '#E2E8F0' }}>{f.total_items}</td>
<td style={{ padding: '0.5rem', textAlign: 'right', color: '#10B981' }}>{f.diff.new_count}</td>
<td style={{ padding: '0.5rem', textAlign: 'right', color: '#F59E0B' }}>{f.diff.recurring_count}</td>
<td style={{ padding: '0.5rem', textAlign: 'right', color: '#0EA5E9' }}>{f.diff.resolved_count}</td>
<td style={{ padding: '0.5rem', textAlign: 'center' }}>
{previewData.files.length > 1 && (
<button
onClick={() => removePreviewFile(i)}
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.2rem' }}
onMouseEnter={e => e.currentTarget.style.color = '#EF4444'}
onMouseLeave={e => e.currentTarget.style.color = '#64748B'}
>
<Trash2 style={{ width: '12px', height: '12px' }} />
</button>
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<td style={{ padding: '0.5rem', fontWeight: '600', color: '#E2E8F0' }}>Total</td>
<td></td>
<td style={{ padding: '0.5rem', textAlign: 'right', fontWeight: '600', color: '#E2E8F0' }}>
{previewData.files.reduce((s, f) => s + f.total_items, 0)}
</td>
<td style={{ padding: '0.5rem', textAlign: 'right', fontWeight: '600', color: '#10B981' }}>
{previewData.files.reduce((s, f) => s + f.diff.new_count, 0)}
</td>
<td style={{ padding: '0.5rem', textAlign: 'right', fontWeight: '600', color: '#F59E0B' }}>
{previewData.files.reduce((s, f) => s + f.diff.recurring_count, 0)}
</td>
<td style={{ padding: '0.5rem', textAlign: 'right', fontWeight: '600', color: '#0EA5E9' }}>
{previewData.files.reduce((s, f) => s + f.diff.resolved_count, 0)}
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
{/* Unrecognized files */}
{previewData.unrecognized && previewData.unrecognized.length > 0 && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.5rem' }}>
<div style={{ fontSize: '0.7rem', color: '#EF4444', fontWeight: '600', marginBottom: '0.5rem' }}>
Unrecognized Files ({previewData.unrecognized.length})
</div>
{previewData.unrecognized.map((u, i) => (
<div key={i} style={{ fontSize: '0.7rem', color: '#F87171', marginBottom: '0.25rem' }}>
{u.filename}: {u.error}
</div>
))}
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
<button
onClick={() => { setPhase('idle'); setPreviewData(null); }}
style={{
flex: 1, padding: '0.7rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.2)',
borderRadius: '0.5rem',
color: '#94A3B8',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
Back
</button>
<button
onClick={handleCommit}
disabled={previewData.files.length === 0}
style={{
flex: 2, padding: '0.7rem',
background: PURPLE,
border: 'none',
borderRadius: '0.5rem',
color: '#FFF',
fontSize: '0.8rem',
fontWeight: '600',
cursor: previewData.files.length === 0 ? 'not-allowed' : 'pointer',
opacity: previewData.files.length === 0 ? 0.5 : 1,
}}
>
Commit {previewData.files.length} File(s)
</button>
</div>
</>
)}
{/* Phase: Committing */}
{phase === 'committing' && (
<div style={{ textAlign: 'center', padding: '3rem' }}>
<Loader style={{ width: '32px', height: '32px', color: PURPLE, animation: 'spin 1s linear infinite', margin: '0 auto 1rem' }} />
<div style={{ fontSize: '0.85rem', color: '#E2E8F0' }}>Committing batch...</div>
<div style={{ fontSize: '0.7rem', color: '#64748B', marginTop: '0.25rem' }}>Writing to database with vertical-scoped resolution</div>
</div>
)}
{/* Phase: Done */}
{phase === 'done' && commitResult && (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<CheckCircle style={{ width: '48px', height: '48px', color: '#10B981', margin: '0 auto 1rem' }} />
<div style={{ fontSize: '1rem', color: '#E2E8F0', marginBottom: '0.5rem' }}>Upload Complete</div>
<div style={{ fontSize: '0.75rem', color: '#94A3B8', marginBottom: '1.5rem' }}>
{commitResult.committed.length} vertical(s) committed {commitResult.total_new} new, {commitResult.total_resolved} resolved
</div>
{/* Per-vertical summary */}
<div style={{ textAlign: 'left', marginBottom: '1.5rem' }}>
{commitResult.committed.map((c, i) => (
<div key={i} style={{ ...FILE_ROW_STYLE, justifyContent: 'space-between' }}>
<span style={{ color: PURPLE, fontWeight: '600', fontSize: '0.8rem' }}>{c.vertical}</span>
<span style={{ fontSize: '0.7rem', color: '#64748B' }}>
+{c.new_count} new / {c.recurring_count} recurring / -{c.resolved_count} resolved
</span>
</div>
))}
</div>
<button
onClick={() => { onUploadComplete(); }}
style={{
padding: '0.7rem 2rem',
background: PURPLE,
border: 'none',
borderRadius: '0.5rem',
color: '#FFF',
fontSize: '0.8rem',
fontWeight: '600',
cursor: 'pointer',
}}
>
Done
</button>
</div>
)}
{/* Error display */}
{error && (
<div style={{ marginTop: '1rem', padding: '0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontSize: '0.75rem', color: '#F87171' }}>{error}</span>
</div>
)}
</div>
</div>
);
}