Add aggregated burndown forecast to CCP Metrics overview page
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
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 { PieChart, Pie, Cell, ComposedChart, Bar, BarChart, 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';
|
||||
@@ -227,6 +227,126 @@ function TrendChart({ months }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aggregated Burndown Chart
|
||||
// ---------------------------------------------------------------------------
|
||||
function AggregatedBurndownChart({ data, loading, error }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem', textAlign: 'center', padding: '2rem' }}>
|
||||
<Loader style={{ animation: 'spin 1s linear infinite', width: '20px', height: '20px', color: '#64748B' }} />
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem', padding: '1.25rem' }}>
|
||||
<div style={{ fontSize: '0.75rem', color: '#EF4444', fontFamily: 'monospace' }}>
|
||||
Error loading burndown data: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
// Empty state: no non-compliant devices
|
||||
if (data.total_non_compliant === 0) {
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem', textAlign: 'center', padding: '2rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', color: '#10B981' }}>No non-compliant devices across any vertical.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// All blockers: no monthly forecast
|
||||
const monthlyKeys = Object.keys(data.monthly_forecast || {});
|
||||
const hasMonthlyData = monthlyKeys.length > 0;
|
||||
|
||||
// Prepare chart data
|
||||
const monthlyData = monthlyKeys
|
||||
.sort()
|
||||
.map(month => ({ month, count: data.monthly_forecast[month] }));
|
||||
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
Aggregated Burndown Forecast
|
||||
</div>
|
||||
|
||||
{/* Summary header */}
|
||||
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase' }}>Non-Compliant</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#EF4444' }}>{data.total_non_compliant.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase' }}>Blockers</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#EF4444' }}>{data.blockers.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase' }}>In-Progress</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#F59E0B' }}>{data.with_dates.toLocaleString()}</div>
|
||||
</div>
|
||||
{data.projected_clear_date && (
|
||||
<div>
|
||||
<div style={{ fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase' }}>Projected Clear</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#10B981' }}>{data.projected_clear_date}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart or blocker message */}
|
||||
{hasMonthlyData ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={monthlyData}>
|
||||
<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="#A78BFA" fillOpacity={0.7} name="Projected Remediations" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div style={{ padding: '1.5rem', textAlign: 'center', color: '#F59E0B', fontSize: '0.8rem' }}>
|
||||
All {data.blockers.toLocaleString()} non-compliant devices lack remediation dates.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-vertical contribution table */}
|
||||
{data.by_vertical && data.by_vertical.length > 0 && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.5rem' }}>
|
||||
By Vertical
|
||||
</div>
|
||||
<table style={{ ...TABLE_STYLE, fontSize: '0.7rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...TH_STYLE, fontSize: '0.6rem' }}>Vertical</th>
|
||||
<th style={{ ...TH_STYLE, fontSize: '0.6rem', textAlign: 'right' }}>Total</th>
|
||||
<th style={{ ...TH_STYLE, fontSize: '0.6rem', textAlign: 'right' }}>Blockers</th>
|
||||
<th style={{ ...TH_STYLE, fontSize: '0.6rem', textAlign: 'right' }}>With Dates</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.by_vertical.map(v => (
|
||||
<tr key={v.vertical}>
|
||||
<td style={{ ...TD_STYLE, color: PURPLE, fontWeight: '600', padding: '0.5rem 1rem' }}>{v.vertical}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', padding: '0.5rem 1rem' }}>{v.total.toLocaleString()}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: v.blockers > 0 ? '#EF4444' : '#64748B', padding: '0.5rem 1rem' }}>{v.blockers.toLocaleString()}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#F59E0B', padding: '0.5rem 1rem' }}>{v.with_dates.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vertical Breakdown Table
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -888,6 +1008,9 @@ export default function CCPMetricsPage() {
|
||||
const { isAdmin, isEditor } = useAuth();
|
||||
const [stats, setStats] = useState(null);
|
||||
const [trend, setTrend] = useState(null);
|
||||
const [burndownData, setBurndownData] = useState(null);
|
||||
const [burndownLoading, setBurndownLoading] = useState(true);
|
||||
const [burndownError, setBurndownError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
@@ -903,6 +1026,8 @@ export default function CCPMetricsPage() {
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setBurndownLoading(true);
|
||||
setBurndownError(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(); }),
|
||||
@@ -914,6 +1039,12 @@ export default function CCPMetricsPage() {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// Fetch burndown independently so a failure doesn't block the rest of the page
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/burndown`, { credentials: 'include' })
|
||||
.then(r => { if (!r.ok) throw new Error('Failed to load burndown'); return r.json(); })
|
||||
.then(data => { setBurndownData(data); setBurndownLoading(false); })
|
||||
.catch(err => { setBurndownError(err.message); setBurndownLoading(false); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
@@ -1060,6 +1191,13 @@ export default function CCPMetricsPage() {
|
||||
<DonutChart donut={stats.donut} />
|
||||
</div>
|
||||
|
||||
{/* Aggregated burndown forecast */}
|
||||
<AggregatedBurndownChart
|
||||
data={burndownData}
|
||||
loading={burndownLoading}
|
||||
error={burndownError}
|
||||
/>
|
||||
|
||||
{/* Vertical breakdown table */}
|
||||
<VerticalTable
|
||||
breakdown={stats.vertical_breakdown}
|
||||
|
||||
Reference in New Issue
Block a user