feat(reporting): add FP Workflow Status donut chart to Metrics panel
Adds a new SVG donut chart showing the distribution of FP workflow states (Actionable, Requested, Reworked, Approved, Rejected, Expired, Unknown) for all findings that have an associated FP# workflow ticket. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -385,6 +385,101 @@ function ActionCoverageDonut({ findings, activeSegment, onSegmentClick }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SVG Donut Chart — FP Workflow Status distribution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const FP_WORKFLOW_DEFS = [
|
||||||
|
{ key: 'Actionable', label: 'Actionable', color: '#F59E0B' },
|
||||||
|
{ key: 'Requested', label: 'Requested', color: '#0EA5E9' },
|
||||||
|
{ key: 'Reworked', label: 'Reworked', color: '#A855F7' },
|
||||||
|
{ key: 'Approved', label: 'Approved', color: '#22C55E' },
|
||||||
|
{ key: 'Rejected', label: 'Rejected', color: '#EF4444' },
|
||||||
|
{ key: 'Expired', label: 'Expired', color: '#64748B' },
|
||||||
|
{ key: 'Unknown', label: 'Unknown', color: '#334155' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function FPWorkflowDonut({ findings }) {
|
||||||
|
const SIZE = 180;
|
||||||
|
const CX = SIZE / 2;
|
||||||
|
const CY = SIZE / 2;
|
||||||
|
const OUTER = 72;
|
||||||
|
const INNER = 48;
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
FP_WORKFLOW_DEFS.forEach(d => { map[d.key] = 0; });
|
||||||
|
findings.forEach((f) => {
|
||||||
|
if (!f.workflow) return;
|
||||||
|
const state = f.workflow.state || '';
|
||||||
|
const def = FP_WORKFLOW_DEFS.find(d => d.key.toLowerCase() === state.toLowerCase());
|
||||||
|
const key = def ? def.key : 'Unknown';
|
||||||
|
map[key] = (map[key] || 0) + 1;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [findings]);
|
||||||
|
|
||||||
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||||
|
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No FP workflows — click Sync to load</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
const segments = FP_WORKFLOW_DEFS.map((def) => {
|
||||||
|
const count = counts[def.key] || 0;
|
||||||
|
const start = cursor;
|
||||||
|
const end = count > 0 ? cursor + (count / total) * 360 : cursor;
|
||||||
|
if (count > 0) cursor = end;
|
||||||
|
return { ...def, count, start, end };
|
||||||
|
}).filter(s => s.count > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||||
|
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
||||||
|
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
|
||||||
|
{segments.map((seg) => (
|
||||||
|
<path
|
||||||
|
key={seg.key}
|
||||||
|
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||||||
|
fill={seg.color}
|
||||||
|
opacity={0.88}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</text>
|
||||||
|
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||||
|
FP TOTAL
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
|
{segments.map((seg) => (
|
||||||
|
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
|
{seg.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||||
|
{seg.count}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||||
|
({((seg.count / total) * 100).toFixed(0)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SortIcon({ colKey, sort }) {
|
function SortIcon({ colKey, sort }) {
|
||||||
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
||||||
return sort.dir === 'asc'
|
return sort.dir === 'asc'
|
||||||
@@ -1257,6 +1352,17 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* FP Workflow Status donut */}
|
||||||
|
<div style={{ flex: '0 0 auto' }}>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||||
|
FP Workflow Status
|
||||||
|
</div>
|
||||||
|
<FPWorkflowDonut findings={findings} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user