425 lines
19 KiB
JavaScript
425 lines
19 KiB
JavaScript
|
|
// ComplianceChartsPanel.js
|
|||
|
|
// Tier-1 time-based compliance charts using Recharts.
|
|||
|
|
// Charts rendered: Active Findings Over Time, Change per Cycle,
|
|||
|
|
// Team Health, MTTR by Team, Persistent Findings, Archer Pipeline.
|
|||
|
|
|
|||
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|||
|
|
import {
|
|||
|
|
LineChart, Line,
|
|||
|
|
BarChart, Bar,
|
|||
|
|
XAxis, YAxis, CartesianGrid,
|
|||
|
|
Tooltip, Legend,
|
|||
|
|
ResponsiveContainer,
|
|||
|
|
} from 'recharts';
|
|||
|
|
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
|
|||
|
|
|
|||
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|||
|
|
|
|||
|
|
const TEAL = '#14B8A6';
|
|||
|
|
|
|||
|
|
const TEAM_COLORS = {
|
|||
|
|
'STEAM': '#0EA5E9',
|
|||
|
|
'ACCESS-ENG': '#F59E0B',
|
|||
|
|
'ACCESS-OPS': '#8B5CF6',
|
|||
|
|
'INTELDEV': '#10B981',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const ARCHER_STATUS_COLORS = {
|
|||
|
|
'Draft': '#475569',
|
|||
|
|
'Open': '#0EA5E9',
|
|||
|
|
'Under Review': '#F59E0B',
|
|||
|
|
'Accepted': '#10B981',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Shared style tokens
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
|
|||
|
|
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
|
|||
|
|
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Custom dark tooltip
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
function DarkTooltip({ active, payload, label }) {
|
|||
|
|
if (!active || !payload?.length) return null;
|
|||
|
|
return (
|
|||
|
|
<div style={{
|
|||
|
|
background: 'rgba(10,17,32,0.97)',
|
|||
|
|
border: '1px solid rgba(20,184,166,0.3)',
|
|||
|
|
borderRadius: '0.375rem',
|
|||
|
|
padding: '0.5rem 0.75rem',
|
|||
|
|
fontFamily: 'monospace',
|
|||
|
|
fontSize: '0.7rem',
|
|||
|
|
minWidth: '130px',
|
|||
|
|
}}>
|
|||
|
|
<div style={{ color: TEAL, marginBottom: '0.3rem', fontWeight: '700', fontSize: '0.65rem' }}>
|
|||
|
|
{label}
|
|||
|
|
</div>
|
|||
|
|
{payload.map(p => (
|
|||
|
|
<div key={p.dataKey} style={{ color: p.color || '#94A3B8', marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
|||
|
|
<span style={{ opacity: 0.8 }}>{p.name}</span>
|
|||
|
|
<span style={{ fontWeight: '700' }}>
|
|||
|
|
{typeof p.value === 'number'
|
|||
|
|
? Number.isInteger(p.value) ? p.value : p.value.toFixed(1)
|
|||
|
|
: p.value}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Chart card wrapper
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
function ChartCard({ title, subtitle, children }) {
|
|||
|
|
return (
|
|||
|
|
<div style={{
|
|||
|
|
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
|||
|
|
border: '1px solid rgba(20,184,166,0.15)',
|
|||
|
|
borderRadius: '0.5rem',
|
|||
|
|
padding: '1rem 1.125rem 0.875rem',
|
|||
|
|
}}>
|
|||
|
|
<div style={{ marginBottom: '0.75rem' }}>
|
|||
|
|
<div style={{
|
|||
|
|
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
|
|||
|
|
color: '#CBD5E1', textTransform: 'uppercase', letterSpacing: '0.08em',
|
|||
|
|
}}>
|
|||
|
|
{title}
|
|||
|
|
</div>
|
|||
|
|
{subtitle && (
|
|||
|
|
<div style={{ fontSize: '0.62rem', color: '#334155', marginTop: '0.2rem', fontFamily: 'monospace' }}>
|
|||
|
|
{subtitle}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Empty / no-data state
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
function NoData({ msg }) {
|
|||
|
|
return (
|
|||
|
|
<div style={{
|
|||
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|||
|
|
height: '160px', color: '#334155',
|
|||
|
|
fontFamily: 'monospace', fontSize: '0.72rem',
|
|||
|
|
border: '1px dashed rgba(20,184,166,0.1)',
|
|||
|
|
borderRadius: '0.375rem',
|
|||
|
|
}}>
|
|||
|
|
{msg || 'No data yet — upload compliance reports to populate this chart'}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Shorten a YYYY-MM-DD string to MM/DD/YY
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
function fmtDate(d) {
|
|||
|
|
if (!d) return '';
|
|||
|
|
const p = d.split('-');
|
|||
|
|
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
|
|||
|
|
return d;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Chart 1 — Active Findings Over Time (line, total + per team)
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
function ActiveTrendChart({ data }) {
|
|||
|
|
if (data.length < 2) return <NoData />;
|
|||
|
|
return (
|
|||
|
|
<ResponsiveContainer width="100%" height={210}>
|
|||
|
|
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
|||
|
|
<CartesianGrid {...GRID_STYLE} />
|
|||
|
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
|||
|
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
|||
|
|
<Tooltip content={<DarkTooltip />} />
|
|||
|
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
|||
|
|
<Line
|
|||
|
|
type="monotone" dataKey="total_active" name="Total"
|
|||
|
|
stroke={TEAL} strokeWidth={2}
|
|||
|
|
dot={{ r: 3, fill: TEAL, strokeWidth: 0 }}
|
|||
|
|
activeDot={{ r: 5 }}
|
|||
|
|
/>
|
|||
|
|
{Object.entries(TEAM_COLORS).map(([team, color]) => (
|
|||
|
|
<Line
|
|||
|
|
key={team}
|
|||
|
|
type="monotone" dataKey={team} name={team}
|
|||
|
|
stroke={color} strokeWidth={1.5}
|
|||
|
|
dot={false} strokeDasharray="5 3"
|
|||
|
|
activeDot={{ r: 4, fill: color }}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</LineChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Chart 2 — New / Recurring / Resolved per cycle (stacked + grouped bar)
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
function DeltaChart({ data }) {
|
|||
|
|
if (data.length === 0) return <NoData />;
|
|||
|
|
return (
|
|||
|
|
<ResponsiveContainer width="100%" height={210}>
|
|||
|
|
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
|||
|
|
<CartesianGrid {...GRID_STYLE} />
|
|||
|
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
|||
|
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
|||
|
|
<Tooltip content={<DarkTooltip />} />
|
|||
|
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
|||
|
|
<Bar dataKey="new_count" name="New" stackId="in" fill="#EF4444" fillOpacity={0.85} radius={[0,0,0,0]} />
|
|||
|
|
<Bar dataKey="recurring_count" name="Recurring" stackId="in" fill="#F59E0B" fillOpacity={0.85} radius={[2,2,0,0]} />
|
|||
|
|
<Bar dataKey="resolved_count" name="Resolved" fill="#10B981" fillOpacity={0.8} radius={[2,2,2,2]} />
|
|||
|
|
</BarChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Chart 3 — Team Health Multi-Line
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
function TeamTrendChart({ data }) {
|
|||
|
|
if (data.length < 2) return <NoData />;
|
|||
|
|
return (
|
|||
|
|
<ResponsiveContainer width="100%" height={210}>
|
|||
|
|
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
|||
|
|
<CartesianGrid {...GRID_STYLE} />
|
|||
|
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
|||
|
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
|||
|
|
<Tooltip content={<DarkTooltip />} />
|
|||
|
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
|||
|
|
{Object.entries(TEAM_COLORS).map(([team, color]) => (
|
|||
|
|
<Line
|
|||
|
|
key={team}
|
|||
|
|
type="monotone" dataKey={team} name={team}
|
|||
|
|
stroke={color} strokeWidth={2}
|
|||
|
|
dot={{ r: 3, fill: color, strokeWidth: 0 }}
|
|||
|
|
activeDot={{ r: 5 }}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</LineChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Chart 4 — MTTR by Team (horizontal bar)
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
function MttrChart({ data }) {
|
|||
|
|
if (data.length === 0) return <NoData msg="No resolved findings yet — MTTR will appear after items are remediated" />;
|
|||
|
|
return (
|
|||
|
|
<ResponsiveContainer width="100%" height={Math.max(160, data.length * 44 + 40)}>
|
|||
|
|
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
|
|||
|
|
<CartesianGrid {...GRID_STYLE} />
|
|||
|
|
<XAxis type="number" tick={AXIS_STYLE} unit=" d" />
|
|||
|
|
<YAxis type="category" dataKey="team" tick={AXIS_STYLE} width={86} />
|
|||
|
|
<Tooltip content={<DarkTooltip />} />
|
|||
|
|
<Bar dataKey="avg_days" name="Avg Days" fill={TEAL} fillOpacity={0.8} radius={[0, 3, 3, 0]}
|
|||
|
|
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}d` }}
|
|||
|
|
/>
|
|||
|
|
</BarChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Chart 5 — Most Persistent Findings (horizontal bar by seen_count)
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
function RecurringChart({ data }) {
|
|||
|
|
if (data.length === 0) return <NoData />;
|
|||
|
|
const top10 = data.slice(0, 10).map(r => ({
|
|||
|
|
...r,
|
|||
|
|
label: r.metric_id.length > 18 ? r.metric_id.slice(0, 18) + '…' : r.metric_id,
|
|||
|
|
}));
|
|||
|
|
return (
|
|||
|
|
<ResponsiveContainer width="100%" height={Math.max(160, top10.length * 28 + 40)}>
|
|||
|
|
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
|
|||
|
|
<CartesianGrid {...GRID_STYLE} />
|
|||
|
|
<XAxis type="number" tick={AXIS_STYLE} unit=" cycles" allowDecimals={false} />
|
|||
|
|
<YAxis type="category" dataKey="label" tick={AXIS_STYLE} width={110} />
|
|||
|
|
<Tooltip content={<DarkTooltip />} formatter={(val, name, props) => [
|
|||
|
|
`${val} cycles (${props.payload.host_count} host${props.payload.host_count !== 1 ? 's' : ''})`, props.payload.team
|
|||
|
|
]} />
|
|||
|
|
<Bar dataKey="seen_count" name="Cycles Seen" fill="#F59E0B" fillOpacity={0.85} radius={[0, 3, 3, 0]}
|
|||
|
|
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}×` }}
|
|||
|
|
/>
|
|||
|
|
</BarChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Chart 6 — Archer Exception Ticket Pipeline (stacked bar by creation date)
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
function ArcherPipelineChart({ data }) {
|
|||
|
|
if (data.length === 0) return <NoData msg="No Archer tickets recorded yet" />;
|
|||
|
|
return (
|
|||
|
|
<ResponsiveContainer width="100%" height={210}>
|
|||
|
|
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
|||
|
|
<CartesianGrid {...GRID_STYLE} />
|
|||
|
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
|||
|
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
|||
|
|
<Tooltip content={<DarkTooltip />} />
|
|||
|
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
|||
|
|
{Object.entries(ARCHER_STATUS_COLORS).map(([status, color], i, arr) => (
|
|||
|
|
<Bar
|
|||
|
|
key={status}
|
|||
|
|
dataKey={status} name={status} stackId="s"
|
|||
|
|
fill={color} fillOpacity={0.85}
|
|||
|
|
radius={i === arr.length - 1 ? [2, 2, 0, 0] : [0, 0, 0, 0]}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</BarChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Main panel
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
export default function ComplianceChartsPanel() {
|
|||
|
|
const [collapsed, setCollapsed] = useState(false);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [trends, setTrends] = useState([]);
|
|||
|
|
const [mttr, setMttr] = useState([]);
|
|||
|
|
const [recurring, setRecurring] = useState([]);
|
|||
|
|
const [archerRaw, setArcherRaw] = useState([]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
let cancelled = false;
|
|||
|
|
const load = async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const [tRes, mRes, rRes, aRes] = await Promise.all([
|
|||
|
|
fetch(`${API_BASE}/compliance/trends`, { credentials: 'include' }),
|
|||
|
|
fetch(`${API_BASE}/compliance/mttr`, { credentials: 'include' }),
|
|||
|
|
fetch(`${API_BASE}/compliance/top-recurring`, { credentials: 'include' }),
|
|||
|
|
fetch(`${API_BASE}/archer-tickets/status-trend`, { credentials: 'include' }),
|
|||
|
|
]);
|
|||
|
|
if (cancelled) return;
|
|||
|
|
if (tRes.ok) { const d = await tRes.json(); setTrends(d.trends || []); }
|
|||
|
|
if (mRes.ok) { const d = await mRes.json(); setMttr(d.mttr || []); }
|
|||
|
|
if (rRes.ok) { const d = await rRes.json(); setRecurring(d.items || []); }
|
|||
|
|
if (aRes.ok) { const d = await aRes.json(); setArcherRaw(d.statusTrend || []); }
|
|||
|
|
} catch { /* silent — charts will show no-data state */ }
|
|||
|
|
finally { if (!cancelled) setLoading(false); }
|
|||
|
|
};
|
|||
|
|
load();
|
|||
|
|
return () => { cancelled = true; };
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
// Format trend rows — add short date label
|
|||
|
|
const formattedTrends = useMemo(
|
|||
|
|
() => trends.map(t => ({ ...t, date: fmtDate(t.report_date) })),
|
|||
|
|
[trends]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Pivot archer raw rows → one object per date
|
|||
|
|
const archerByDate = useMemo(() => {
|
|||
|
|
if (!archerRaw.length) return [];
|
|||
|
|
const map = {};
|
|||
|
|
archerRaw.forEach(r => {
|
|||
|
|
if (!map[r.date]) map[r.date] = { date: fmtDate(r.date) };
|
|||
|
|
map[r.date][r.status] = r.count;
|
|||
|
|
});
|
|||
|
|
return Object.values(map).sort((a, b) => a.date.localeCompare(b.date));
|
|||
|
|
}, [archerRaw]);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div style={{ marginBottom: '1.5rem' }}>
|
|||
|
|
|
|||
|
|
{/* ── Section header / collapse toggle ──────────────────────── */}
|
|||
|
|
<button
|
|||
|
|
onClick={() => setCollapsed(c => !c)}
|
|||
|
|
style={{
|
|||
|
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|||
|
|
background: 'none', border: 'none', cursor: 'pointer',
|
|||
|
|
padding: '0 0 0.625rem 0',
|
|||
|
|
borderBottom: collapsed ? 'none' : '1px solid rgba(20,184,166,0.1)',
|
|||
|
|
marginBottom: collapsed ? 0 : '0.875rem',
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|||
|
|
<TrendingUp style={{ width: '14px', height: '14px', color: TEAL }} />
|
|||
|
|
<span style={{
|
|||
|
|
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
|
|||
|
|
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
|
|||
|
|
}}>
|
|||
|
|
Historical Trends
|
|||
|
|
</span>
|
|||
|
|
{loading && (
|
|||
|
|
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
{collapsed
|
|||
|
|
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
|
|||
|
|
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
|
|||
|
|
}
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
{!collapsed && (
|
|||
|
|
<div style={{
|
|||
|
|
display: 'grid',
|
|||
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(420px, 1fr))',
|
|||
|
|
gap: '1rem',
|
|||
|
|
}}>
|
|||
|
|
|
|||
|
|
{/* 1. Active findings over time */}
|
|||
|
|
<ChartCard
|
|||
|
|
title="Active Findings Over Time"
|
|||
|
|
subtitle="Total non-compliant items per report cycle (solid) + per team (dashed)"
|
|||
|
|
>
|
|||
|
|
<ActiveTrendChart data={formattedTrends} />
|
|||
|
|
</ChartCard>
|
|||
|
|
|
|||
|
|
{/* 2. New / Recurring / Resolved delta per cycle */}
|
|||
|
|
<ChartCard
|
|||
|
|
title="Change per Report Cycle"
|
|||
|
|
subtitle="New (red) and recurring (amber) stacked; resolved (green) as separate bars"
|
|||
|
|
>
|
|||
|
|
<DeltaChart data={formattedTrends} />
|
|||
|
|
</ChartCard>
|
|||
|
|
|
|||
|
|
{/* 3. Team health multi-line */}
|
|||
|
|
<ChartCard
|
|||
|
|
title="Team Compliance Health"
|
|||
|
|
subtitle="Active findings per team per cycle — lower is better"
|
|||
|
|
>
|
|||
|
|
<TeamTrendChart data={formattedTrends} />
|
|||
|
|
</ChartCard>
|
|||
|
|
|
|||
|
|
{/* 4. MTTR per team */}
|
|||
|
|
<ChartCard
|
|||
|
|
title="Mean Time to Resolution"
|
|||
|
|
subtitle="Average calendar days between first-seen and resolved, by team"
|
|||
|
|
>
|
|||
|
|
<MttrChart data={mttr} />
|
|||
|
|
</ChartCard>
|
|||
|
|
|
|||
|
|
{/* 5. Most persistent / recurring findings */}
|
|||
|
|
<ChartCard
|
|||
|
|
title="Most Persistent Findings"
|
|||
|
|
subtitle="Active items with the highest recurrence count (top 10)"
|
|||
|
|
>
|
|||
|
|
<RecurringChart data={recurring} />
|
|||
|
|
</ChartCard>
|
|||
|
|
|
|||
|
|
{/* 6. Archer ticket pipeline */}
|
|||
|
|
<ChartCard
|
|||
|
|
title="Archer Exception Pipeline"
|
|||
|
|
subtitle="Exception ticket status distribution by creation date"
|
|||
|
|
>
|
|||
|
|
<ArcherPipelineChart data={archerByDate} />
|
|||
|
|
</ChartCard>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|