Compare commits
1 Commits
feature/re
...
a9404ff82a
| Author | SHA1 | Date | |
|---|---|---|---|
| a9404ff82a |
@@ -264,6 +264,101 @@ function StatusDonut({ open, closed, loading }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG Donut Chart — FP# workflow state distribution
|
||||
// ---------------------------------------------------------------------------
|
||||
const WF_STATE_DEFS = [
|
||||
{ key: 'expired', label: 'Expired', color: '#EF4444' },
|
||||
{ key: 'rejected', label: 'Rejected', color: '#F87171' },
|
||||
{ key: 'reworked', label: 'Reworked', color: '#F59E0B' },
|
||||
{ key: 'actionable', label: 'Actionable', color: '#FCD34D' },
|
||||
{ key: 'requested', label: 'Requested', color: '#0EA5E9' },
|
||||
{ key: 'approved', label: 'Approved', color: '#10B981' },
|
||||
{ key: 'none', label: 'No FP#', color: '#334155' },
|
||||
];
|
||||
|
||||
function WorkflowDonut({ findings }) {
|
||||
const SIZE = 180;
|
||||
const CX = SIZE / 2;
|
||||
const CY = SIZE / 2;
|
||||
const OUTER = 72;
|
||||
const INNER = 48;
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const map = Object.fromEntries(WF_STATE_DEFS.map((d) => [d.key, 0]));
|
||||
findings.forEach((f) => {
|
||||
const state = (f.workflow?.state || '').toLowerCase();
|
||||
if (state && state in map) map[state]++;
|
||||
else map.none++;
|
||||
});
|
||||
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 data — click Sync to load</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
const segments = WF_STATE_DEFS
|
||||
.map((def) => {
|
||||
const count = counts[def.key];
|
||||
if (!count) return null;
|
||||
const start = cursor;
|
||||
const end = cursor + (count / total) * 360;
|
||||
cursor = end;
|
||||
return { ...def, count, start, end };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
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' }}>
|
||||
TOTAL
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{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.75rem', 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 }) {
|
||||
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
||||
return sort.dir === 'asc'
|
||||
@@ -962,7 +1057,7 @@ export default function ReportingPage({ filterDate }) {
|
||||
Metric Graphs
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
{/* Open vs Closed 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' }}>
|
||||
@@ -974,6 +1069,17 @@ export default function ReportingPage({ filterDate }) {
|
||||
loading={countsLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||||
|
||||
{/* FP# Workflow state 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>
|
||||
<WorkflowDonut findings={findings} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user