1123 lines
63 KiB
JavaScript
1123 lines
63 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { Upload, Building2, ChevronLeft, Loader, AlertCircle, BarChart3, Settings, Trash2, RotateCcw } 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, onNonCompliantClick, ncExpanded }) {
|
|
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', clickable: true },
|
|
{ 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, clickable }) => (
|
|
<div
|
|
key={label}
|
|
onClick={clickable ? onNonCompliantClick : undefined}
|
|
style={{
|
|
...STAT_CARD_STYLE,
|
|
cursor: clickable ? 'pointer' : 'default',
|
|
border: clickable && ncExpanded
|
|
? '1px solid rgba(239, 68, 68, 0.6)'
|
|
: STAT_CARD_STYLE.border,
|
|
background: clickable && ncExpanded
|
|
? 'rgba(239, 68, 68, 0.08)'
|
|
: STAT_CARD_STYLE.background,
|
|
transition: 'all 0.15s',
|
|
}}
|
|
onMouseEnter={clickable ? e => { e.currentTarget.style.borderColor = 'rgba(239, 68, 68, 0.5)'; } : undefined}
|
|
onMouseLeave={clickable ? e => { if (!ncExpanded) e.currentTarget.style.borderColor = 'rgba(167, 139, 250, 0.2)'; } : undefined}
|
|
>
|
|
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.5rem' }}>
|
|
{label}{clickable && <span style={{ marginLeft: '0.4rem', fontSize: '0.6rem', color: '#64748B' }}>{ncExpanded ? '▾' : '▸'}</span>}
|
|
</div>
|
|
<div style={{ fontSize: '1.5rem', fontWeight: '700', color }}>{value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Metric Breakdown Panel (shown when Non-Compliant is clicked)
|
|
// ---------------------------------------------------------------------------
|
|
function MetricBreakdownPanel({ metrics }) {
|
|
const [showAll, setShowAll] = useState(false);
|
|
|
|
if (!metrics || metrics.length === 0) return null;
|
|
|
|
// Only show metrics with non_compliant > 0
|
|
const ncMetrics = metrics.filter(m => m.non_compliant > 0);
|
|
if (ncMetrics.length === 0) return null;
|
|
|
|
const TOP_COUNT = 8;
|
|
const displayMetrics = showAll ? ncMetrics : ncMetrics.slice(0, TOP_COUNT);
|
|
const hasMore = ncMetrics.length > TOP_COUNT;
|
|
|
|
return (
|
|
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
|
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
Non-Compliant by Metric
|
|
</div>
|
|
{hasMore && (
|
|
<button
|
|
onClick={() => setShowAll(!showAll)}
|
|
style={{ background: 'none', border: 'none', color: PURPLE, fontSize: '0.7rem', cursor: 'pointer', padding: '0.2rem 0.5rem' }}
|
|
>
|
|
{showAll ? 'Show top 8' : `Show all ${ncMetrics.length}`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: '0.5rem' }}>
|
|
{displayMetrics.map(m => {
|
|
const pct = m.total > 0 ? (m.compliant / m.total) : 0;
|
|
const target = Number(m.target || 0);
|
|
const pctColor = pct >= target ? '#10B981' : pct >= target * 0.85 ? '#F59E0B' : '#EF4444';
|
|
return (
|
|
<div
|
|
key={m.metric_id}
|
|
style={{
|
|
background: 'rgba(15, 23, 42, 0.7)',
|
|
border: `1px solid ${pctColor}30`,
|
|
borderRadius: '0.4rem',
|
|
padding: '0.6rem 0.75rem',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.3rem' }}>
|
|
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0' }}>{m.metric_id}</span>
|
|
<span style={{ fontSize: '0.55rem', fontFamily: 'monospace', color: pctColor }}>{(pct * 100).toFixed(0)}%</span>
|
|
</div>
|
|
<div style={{ fontSize: '1rem', fontWeight: '700', color: '#EF4444' }}>{m.non_compliant.toLocaleString()}</div>
|
|
</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 [teams, setTeams] = useState([]);
|
|
const [burndown, setBurndown] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [expandedMetrics, setExpandedMetrics] = useState(new Set());
|
|
const [teamFilter, setTeamFilter] = useState(''); // '' = all teams (rollup view)
|
|
// ⚠️ CONVENTION: Missing error state — .catch() silently swallows errors without displaying them to the user. Add an error state and render an error message (see main CCPMetricsPage pattern).
|
|
|
|
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 || []);
|
|
setTeams(metricsData.teams || []);
|
|
setBurndown(burndownData);
|
|
setLoading(false);
|
|
}).catch(() => setLoading(false));
|
|
}, [vertical]);
|
|
|
|
const toggleMetricExpand = (metricId) => {
|
|
setExpandedMetrics(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(metricId)) next.delete(metricId);
|
|
else next.add(metricId);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
|
|
|
|
// Filter metrics by team if a team filter is active
|
|
const displayMetrics = teamFilter
|
|
? metrics.filter(m => m.sub_teams && m.sub_teams.some(st => st.team === teamFilter))
|
|
: metrics;
|
|
|
|
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>
|
|
)}
|
|
|
|
{/* Team filter */}
|
|
{teams.length > 0 && (
|
|
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem', padding: '0.75rem 1.25rem' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
|
<span style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Filter by Team:</span>
|
|
<button
|
|
onClick={() => setTeamFilter('')}
|
|
style={{
|
|
padding: '0.3rem 0.7rem',
|
|
background: !teamFilter ? PURPLE : 'transparent',
|
|
border: `1px solid ${!teamFilter ? PURPLE : 'rgba(255,255,255,0.15)'}`,
|
|
borderRadius: '0.375rem',
|
|
color: !teamFilter ? '#FFF' : '#94A3B8',
|
|
fontSize: '0.7rem',
|
|
cursor: 'pointer',
|
|
fontWeight: !teamFilter ? '600' : '400',
|
|
}}
|
|
>
|
|
All (Rollup)
|
|
</button>
|
|
{teams.map(t => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTeamFilter(t === teamFilter ? '' : t)}
|
|
style={{
|
|
padding: '0.3rem 0.7rem',
|
|
background: teamFilter === t ? TEAL : 'transparent',
|
|
border: `1px solid ${teamFilter === t ? TEAL : 'rgba(255,255,255,0.15)'}`,
|
|
borderRadius: '0.375rem',
|
|
color: teamFilter === t ? '#FFF' : '#94A3B8',
|
|
fontSize: '0.7rem',
|
|
cursor: 'pointer',
|
|
fontWeight: teamFilter === t ? '600' : '400',
|
|
}}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Metrics table with expandable sub-team rows */}
|
|
<div style={CARD_STYLE}>
|
|
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
|
Metrics {teamFilter && <span style={{ color: TEAL, fontWeight: '600' }}>— {teamFilter}</span>}
|
|
</div>
|
|
<table style={TABLE_STYLE}>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ ...TH_STYLE, width: '30px' }}></th>
|
|
<th style={TH_STYLE}>Metric</th>
|
|
<th style={TH_STYLE}>Description</th>
|
|
<th style={TH_STYLE}>Category</th>
|
|
<th style={TH_STYLE}>{teamFilter ? 'Team' : ''}</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>
|
|
{displayMetrics && displayMetrics.map((m) => {
|
|
const hasSubTeams = m.sub_teams && m.sub_teams.length > 0;
|
|
const isExpanded = expandedMetrics.has(m.metric_id);
|
|
|
|
// If team filter is active, show the filtered team's data instead of rollup
|
|
const displayRow = teamFilter && m.sub_teams
|
|
? m.sub_teams.find(st => st.team === teamFilter) || m
|
|
: m;
|
|
const displayPct = Number(displayRow.compliance_pct || 0);
|
|
const targetVal = Number(m.target || 0);
|
|
const pctColor = displayPct >= targetVal ? '#10B981' : displayPct >= (targetVal * 0.85) ? '#F59E0B' : '#EF4444';
|
|
|
|
return (
|
|
<React.Fragment key={m.metric_id}>
|
|
{/* Primary metric row */}
|
|
<tr
|
|
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, padding: '0.5rem', textAlign: 'center' }}>
|
|
{hasSubTeams && !teamFilter && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); toggleMetricExpand(m.metric_id); }}
|
|
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', fontSize: '0.75rem', padding: '0.2rem' }}
|
|
title={isExpanded ? 'Collapse sub-teams' : 'Expand sub-teams'}
|
|
>
|
|
{isExpanded ? '▾' : '▸'}
|
|
</button>
|
|
)}
|
|
</td>
|
|
<td
|
|
style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}
|
|
onClick={() => onSelectMetric(m.metric_id, m)}
|
|
>
|
|
{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, fontSize: '0.7rem', color: TEAL }}>{teamFilter || ''}</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{(displayRow.compliant || 0).toLocaleString()}</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{(displayRow.non_compliant || 0).toLocaleString()}</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{(displayRow.total || 0).toLocaleString()}</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: pctColor }}>{(displayPct * 100).toFixed(1)}%</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#64748B' }}>{(targetVal * 100).toFixed(0)}%</td>
|
|
</tr>
|
|
|
|
{/* Expanded sub-team rows */}
|
|
{isExpanded && !teamFilter && hasSubTeams && m.sub_teams.map(st => {
|
|
const stPct = Number(st.compliance_pct || 0);
|
|
const stPctColor = stPct >= targetVal ? '#10B981' : stPct >= (targetVal * 0.85) ? '#F59E0B' : '#EF4444';
|
|
return (
|
|
<tr key={`${m.metric_id}-${st.team}`} style={{ background: 'rgba(20, 184, 166, 0.03)' }}>
|
|
<td style={{ ...TD_STYLE, padding: '0.4rem' }}></td>
|
|
<td style={{ ...TD_STYLE, paddingLeft: '1.5rem', fontSize: '0.7rem', color: '#64748B' }}>└</td>
|
|
<td style={{ ...TD_STYLE, fontSize: '0.7rem', color: '#94A3B8' }}></td>
|
|
<td style={{ ...TD_STYLE, fontSize: '0.7rem' }}></td>
|
|
<td style={{ ...TD_STYLE, fontSize: '0.75rem', color: TEAL, fontWeight: '500' }}>{st.team}</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981', fontSize: '0.75rem' }}>{(st.compliant || 0).toLocaleString()}</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444', fontSize: '0.75rem' }}>{(st.non_compliant || 0).toLocaleString()}</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right', fontSize: '0.75rem' }}>{(st.total || 0).toLocaleString()}</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '600', color: stPctColor, fontSize: '0.75rem' }}>{(stPct * 100).toFixed(1)}%</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#64748B', fontSize: '0.75rem' }}></td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Metric Sub-Team View (intermediate drill-down: metric → sub-teams)
|
|
// ---------------------------------------------------------------------------
|
|
function MetricSubTeamView({ vertical, metricId, metricData, onBack, onSelectTeam }) {
|
|
// metricData contains the metric's sub_teams from the parent view
|
|
const pctColor = (pct, target) => {
|
|
const p = Number(pct || 0);
|
|
const t = Number(target || 0);
|
|
return p >= t ? '#10B981' : p >= t * 0.85 ? '#F59E0B' : '#EF4444';
|
|
};
|
|
|
|
const targetVal = Number(metricData?.target || 0);
|
|
|
|
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: '1.1rem', color: '#E2E8F0', marginBottom: '0.25rem', fontWeight: '700' }}>
|
|
{vertical} / Metric {metricId}
|
|
</h3>
|
|
{metricData?.metric_desc && (
|
|
<p style={{ fontSize: '0.75rem', color: '#94A3B8', margin: '0 0 1.5rem 0' }}>{metricData.metric_desc}</p>
|
|
)}
|
|
|
|
{/* Metric rollup stats */}
|
|
<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' }}>Total</div>
|
|
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#E2E8F0' }}>{(metricData?.total || 0).toLocaleString()}</div>
|
|
</div>
|
|
<div style={STAT_CARD_STYLE}>
|
|
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Compliant</div>
|
|
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#10B981' }}>{(metricData?.compliant || 0).toLocaleString()}</div>
|
|
</div>
|
|
<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' }}>{(metricData?.non_compliant || 0).toLocaleString()}</div>
|
|
</div>
|
|
<div style={STAT_CARD_STYLE}>
|
|
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Compliance</div>
|
|
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: pctColor(metricData?.compliance_pct, metricData?.target) }}>
|
|
{(Number(metricData?.compliance_pct || 0) * 100).toFixed(1)}%
|
|
</div>
|
|
</div>
|
|
<div style={STAT_CARD_STYLE}>
|
|
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Target</div>
|
|
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: PURPLE }}>{(targetVal * 100).toFixed(0)}%</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sub-team breakdown table */}
|
|
<div style={CARD_STYLE}>
|
|
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
|
Sub-Team Breakdown
|
|
</div>
|
|
{metricData?.sub_teams && metricData.sub_teams.length > 0 ? (
|
|
<table style={TABLE_STYLE}>
|
|
<thead>
|
|
<tr>
|
|
<th style={TH_STYLE}>Team</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' }}>Compliance %</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{metricData.sub_teams.map(st => {
|
|
const stPct = Number(st.compliance_pct || 0);
|
|
const stPctColor = pctColor(st.compliance_pct, metricData.target);
|
|
return (
|
|
<tr
|
|
key={st.team}
|
|
onClick={() => onSelectTeam(st.team)}
|
|
style={{ cursor: 'pointer', transition: 'background 0.15s' }}
|
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(20, 184, 166, 0.08)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
>
|
|
<td style={{ ...TD_STYLE, fontWeight: '600', color: TEAL }}>{st.team}</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{(st.compliant || 0).toLocaleString()}</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{(st.non_compliant || 0).toLocaleString()}</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{(st.total || 0).toLocaleString()}</td>
|
|
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: stPctColor }}>{(stPct * 100).toFixed(1)}%</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
<div style={{ color: '#64748B', fontSize: '0.8rem', padding: '1rem', textAlign: 'center' }}>
|
|
No sub-team breakdown available for this metric.
|
|
<div style={{ marginTop: '0.75rem' }}>
|
|
<button
|
|
onClick={() => onSelectTeam(null)}
|
|
style={{ padding: '0.5rem 1rem', background: `${PURPLE}20`, border: `1px solid ${PURPLE}60`, borderRadius: '0.5rem', color: PURPLE, fontSize: '0.75rem', cursor: 'pointer', fontWeight: '600' }}
|
|
>
|
|
View All Devices
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Metric Device List (deepest drill-down — filtered by team)
|
|
// ---------------------------------------------------------------------------
|
|
function MetricDeviceList({ vertical, metricId, team, onBack }) {
|
|
const [devices, setDevices] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
// ⚠️ CONVENTION: Missing error state — .catch() below silently swallows errors without displaying them to the user. Add an error state and render an error message.
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
let url = `${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}/metric/${encodeURIComponent(metricId)}/devices`;
|
|
if (team) url += `?team=${encodeURIComponent(team)}`;
|
|
fetch(url, { credentials: 'include' })
|
|
.then(r => r.json())
|
|
.then(data => { setDevices(data.devices || []); setLoading(false); })
|
|
.catch(() => setLoading(false));
|
|
}, [vertical, metricId, team]);
|
|
|
|
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 Sub-Teams
|
|
</button>
|
|
|
|
<h3 style={{ fontSize: '1rem', color: '#E2E8F0', marginBottom: '1rem' }}>
|
|
{vertical} / Metric {metricId}{team ? ` / ${team}` : ''} — {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>
|
|
);
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Data Management Panel
|
|
// ---------------------------------------------------------------------------
|
|
function DataManagementPanel({ onClose, onDataChanged }) {
|
|
const [uploads, setUploads] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [actionLoading, setActionLoading] = useState(null);
|
|
const [confirmAction, setConfirmAction] = useState(null); // { type, label, action }
|
|
const [message, setMessage] = useState(null);
|
|
|
|
useEffect(() => {
|
|
fetchUploads();
|
|
}, []);
|
|
|
|
const fetchUploads = () => {
|
|
setLoading(true);
|
|
fetch(`${API_BASE}/compliance/vcl-multi/uploads`, { credentials: 'include' })
|
|
.then(r => r.json())
|
|
.then(data => { setUploads(data.uploads || []); setLoading(false); })
|
|
.catch(() => setLoading(false));
|
|
};
|
|
|
|
const handleDeleteVertical = async (vertical) => {
|
|
setActionLoading(vertical);
|
|
setMessage(null);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}`, {
|
|
method: 'DELETE', credentials: 'include',
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { setMessage({ type: 'error', text: data.error }); }
|
|
else { setMessage({ type: 'success', text: data.message }); fetchUploads(); onDataChanged(); }
|
|
} catch (err) { setMessage({ type: 'error', text: err.message }); }
|
|
setActionLoading(null);
|
|
setConfirmAction(null);
|
|
};
|
|
|
|
const handleRollbackUpload = async (uploadId) => {
|
|
setActionLoading(uploadId);
|
|
setMessage(null);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/compliance/vcl-multi/upload/${uploadId}`, {
|
|
method: 'DELETE', credentials: 'include',
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { setMessage({ type: 'error', text: data.error }); }
|
|
else { setMessage({ type: 'success', text: data.message }); fetchUploads(); onDataChanged(); }
|
|
} catch (err) { setMessage({ type: 'error', text: err.message }); }
|
|
setActionLoading(null);
|
|
setConfirmAction(null);
|
|
};
|
|
|
|
const handleDeleteAll = async () => {
|
|
setActionLoading('all');
|
|
setMessage(null);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/compliance/vcl-multi/all`, {
|
|
method: 'DELETE', credentials: 'include',
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { setMessage({ type: 'error', text: data.error }); }
|
|
else { setMessage({ type: 'success', text: data.message }); fetchUploads(); onDataChanged(); }
|
|
} catch (err) { setMessage({ type: 'error', text: err.message }); }
|
|
setActionLoading(null);
|
|
setConfirmAction(null);
|
|
};
|
|
|
|
// Group uploads by vertical
|
|
const verticalGroups = {};
|
|
for (const u of uploads) {
|
|
if (!verticalGroups[u.vertical]) verticalGroups[u.vertical] = [];
|
|
verticalGroups[u.vertical].push(u);
|
|
}
|
|
|
|
return (
|
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onClose}>
|
|
<div style={{ background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '1rem', width: '90%', maxWidth: '800px', maxHeight: '80vh', overflow: 'auto', padding: '2rem' }} onClick={e => e.stopPropagation()}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
|
<h2 style={{ fontSize: '1.1rem', fontWeight: '700', color: '#E2E8F0', margin: 0 }}>Manage Data</h2>
|
|
{/* ⚠️ CONVENTION: Use lucide-react <X /> icon instead of raw Unicode character */}
|
|
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>✕</button>
|
|
</div>
|
|
|
|
{/* Message */}
|
|
{message && (
|
|
<div style={{ marginBottom: '1rem', padding: '0.75rem', borderRadius: '0.5rem', background: message.type === 'error' ? 'rgba(239,68,68,0.1)' : 'rgba(16,185,129,0.1)', border: `1px solid ${message.type === 'error' ? 'rgba(239,68,68,0.3)' : 'rgba(16,185,129,0.3)'}`, fontSize: '0.75rem', color: message.type === 'error' ? '#F87171' : '#6EE7B7' }}>
|
|
{message.text}
|
|
</div>
|
|
)}
|
|
|
|
{/* Confirm dialog */}
|
|
{confirmAction && (
|
|
<div style={{ marginBottom: '1rem', padding: '1rem', borderRadius: '0.5rem', background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.4)' }}>
|
|
<div style={{ fontSize: '0.8rem', color: '#F87171', marginBottom: '0.75rem' }}>
|
|
{confirmAction.label}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
|
<button onClick={() => setConfirmAction(null)} style={{ padding: '0.4rem 1rem', background: 'transparent', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '0.375rem', color: '#94A3B8', fontSize: '0.75rem', cursor: 'pointer' }}>Cancel</button>
|
|
<button onClick={confirmAction.action} style={{ padding: '0.4rem 1rem', background: '#EF4444', border: 'none', borderRadius: '0.375rem', color: '#FFF', fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer' }}>Confirm Delete</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete All button */}
|
|
<div style={{ marginBottom: '1.5rem', paddingBottom: '1rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
|
<button
|
|
onClick={() => setConfirmAction({ label: 'Delete ALL multi-vertical data? This cannot be undone.', action: handleDeleteAll })}
|
|
disabled={uploads.length === 0 || actionLoading}
|
|
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem', background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '0.5rem', color: '#EF4444', fontSize: '0.75rem', cursor: uploads.length === 0 ? 'not-allowed' : 'pointer', opacity: uploads.length === 0 ? 0.5 : 1 }}
|
|
>
|
|
<Trash2 style={{ width: '13px', height: '13px' }} />
|
|
Reset All Data
|
|
</button>
|
|
</div>
|
|
|
|
{loading && <div style={{ textAlign: 'center', color: '#64748B', fontSize: '0.8rem', padding: '2rem' }}>Loading uploads...</div>}
|
|
|
|
{!loading && uploads.length === 0 && (
|
|
<div style={{ textAlign: 'center', color: '#64748B', fontSize: '0.8rem', padding: '2rem' }}>No uploads yet.</div>
|
|
)}
|
|
|
|
{/* Per-vertical sections */}
|
|
{!loading && Object.entries(verticalGroups).map(([vertical, vUploads]) => (
|
|
<div key={vertical} style={{ marginBottom: '1.5rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
|
<span style={{ fontSize: '0.8rem', fontWeight: '600', color: PURPLE }}>{vertical}</span>
|
|
<button
|
|
onClick={() => setConfirmAction({ label: `Delete all data for "${vertical}"? This removes all uploads and items for this vertical.`, action: () => handleDeleteVertical(vertical) })}
|
|
disabled={actionLoading}
|
|
style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', padding: '0.3rem 0.6rem', background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '0.375rem', color: '#EF4444', fontSize: '0.65rem', cursor: 'pointer' }}
|
|
>
|
|
<Trash2 style={{ width: '11px', height: '11px' }} /> Delete Vertical
|
|
</button>
|
|
</div>
|
|
{vUploads.slice(0, 5).map((u, i) => (
|
|
<div key={u.id} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.5rem 0.75rem', background: 'rgba(15,23,42,0.5)', borderRadius: '0.375rem', marginBottom: '0.25rem', fontSize: '0.7rem' }}>
|
|
<span style={{ color: '#94A3B8', flex: 1 }}>{u.filename}</span>
|
|
<span style={{ color: '#64748B' }}>{u.report_date || '—'}</span>
|
|
<span style={{ color: '#64748B', fontSize: '0.6rem' }}>+{u.new_count || 0} / -{u.resolved_count || 0}</span>
|
|
{i === 0 && (
|
|
<button
|
|
onClick={() => setConfirmAction({ label: `Rollback "${u.filename}"? New items will be deleted and resolved items reactivated.`, action: () => handleRollbackUpload(u.id) })}
|
|
disabled={actionLoading}
|
|
style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', padding: '0.2rem 0.5rem', background: 'none', border: '1px solid rgba(245,158,11,0.4)', borderRadius: '0.25rem', color: '#F59E0B', fontSize: '0.6rem', cursor: 'pointer' }}
|
|
>
|
|
<RotateCcw style={{ width: '10px', height: '10px' }} /> Rollback
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
{vUploads.length > 5 && (
|
|
<div style={{ fontSize: '0.6rem', color: '#64748B', paddingLeft: '0.75rem' }}>...and {vUploads.length - 5} more</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</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);
|
|
const [showManage, setShowManage] = useState(false);
|
|
const [showMetricBreakdown, setShowMetricBreakdown] = useState(false);
|
|
|
|
// Drill-down state
|
|
const [selectedVertical, setSelectedVertical] = useState(null);
|
|
const [selectedMetric, setSelectedMetric] = useState(null);
|
|
const [selectedMetricData, setSelectedMetricData] = useState(null);
|
|
const [selectedTeam, setSelectedTeam] = 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 (selectedTeam !== null && selectedMetric && selectedVertical) {
|
|
return (
|
|
<div style={PAGE_STYLE}>
|
|
<MetricDeviceList
|
|
vertical={selectedVertical}
|
|
metricId={selectedMetric}
|
|
team={selectedTeam}
|
|
onBack={() => setSelectedTeam(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (selectedMetric && selectedVertical) {
|
|
return (
|
|
<div style={PAGE_STYLE}>
|
|
<MetricSubTeamView
|
|
vertical={selectedVertical}
|
|
metricId={selectedMetric}
|
|
metricData={selectedMetricData}
|
|
onBack={() => { setSelectedMetric(null); setSelectedMetricData(null); }}
|
|
onSelectTeam={(team) => setSelectedTeam(team)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (selectedVertical) {
|
|
return (
|
|
<div style={PAGE_STYLE}>
|
|
<VerticalDetailView
|
|
vertical={selectedVertical}
|
|
onBack={() => setSelectedVertical(null)}
|
|
onSelectMetric={(metricId, metricData) => { setSelectedMetric(metricId); setSelectedMetricData(metricData); }}
|
|
/>
|
|
</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()) && (
|
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
|
{isAdmin() && (
|
|
<button
|
|
onClick={() => setShowManage(true)}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
|
padding: '0.6rem 1rem',
|
|
background: 'rgba(239, 68, 68, 0.1)',
|
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
|
borderRadius: '0.5rem',
|
|
color: '#EF4444',
|
|
fontSize: '0.75rem',
|
|
fontWeight: '600',
|
|
cursor: 'pointer',
|
|
transition: 'background 0.15s',
|
|
}}
|
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(239, 68, 68, 0.2)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)'}
|
|
>
|
|
<Settings style={{ width: '14px', height: '14px' }} />
|
|
Manage
|
|
</button>
|
|
)}
|
|
<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>
|
|
)}
|
|
</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}
|
|
onNonCompliantClick={() => setShowMetricBreakdown(!showMetricBreakdown)}
|
|
ncExpanded={showMetricBreakdown}
|
|
/>
|
|
|
|
{/* Metric breakdown (revealed when Non-Compliant is clicked) */}
|
|
{showMetricBreakdown && (
|
|
<MetricBreakdownPanel metrics={stats.metric_breakdown} />
|
|
)}
|
|
|
|
{/* 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}
|
|
/>
|
|
)}
|
|
|
|
{/* Data Management Panel */}
|
|
{showManage && (
|
|
<DataManagementPanel
|
|
onClose={() => setShowManage(false)}
|
|
onDataChanged={fetchData}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|