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:
@@ -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' },
|
||||
|
||||
602
frontend/src/components/pages/CCPMetricsPage.js
Normal file
602
frontend/src/components/pages/CCPMetricsPage.js
Normal 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>
|
||||
);
|
||||
}
|
||||
428
frontend/src/components/pages/MultiVerticalUploadModal.js
Normal file
428
frontend/src/components/pages/MultiVerticalUploadModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user