Add VCL vertical metadata: inline-editable team fields, JSDoc on compliance routes, stats query rewrite
This commit is contained in:
@@ -183,10 +183,89 @@ function NonCompliantDonutChart({ donut }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline Editable Cell Component
|
||||
// ---------------------------------------------------------------------------
|
||||
function EditableCell({ value, type, onSave, style, placeholder }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value || '');
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value || '');
|
||||
}, [value]);
|
||||
|
||||
const handleSave = () => {
|
||||
setEditing(false);
|
||||
const newValue = type === 'number' ? (draft === '' ? 0 : parseInt(draft, 10)) : draft;
|
||||
if (newValue !== value) {
|
||||
onSave(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
setDraft(value || '');
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
const inputStyle = {
|
||||
background: 'rgba(15,23,42,0.9)',
|
||||
border: `1px solid ${TEAL}`,
|
||||
borderRadius: '0.25rem',
|
||||
color: '#CBD5E1',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
padding: '0.25rem 0.4rem',
|
||||
width: '100%',
|
||||
outline: 'none',
|
||||
boxShadow: `0 0 0 1px ${TEAL}40`,
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type || 'text'}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ ...inputStyle, ...style }}
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const displayValue = type === 'number' ? (value || 0) : (value || '');
|
||||
return (
|
||||
<span
|
||||
onClick={() => setEditing(true)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'inline-block',
|
||||
minWidth: '30px',
|
||||
minHeight: '1.2em',
|
||||
padding: '0.1rem 0.25rem',
|
||||
borderRadius: '0.2rem',
|
||||
transition: 'background 0.15s',
|
||||
...(style || {}),
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(20,184,166,0.08)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||
title="Click to edit"
|
||||
>
|
||||
{displayValue || <span style={{ color: '#475569', fontStyle: 'italic' }}>{placeholder || '—'}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heavy Hitters Table (Task 14)
|
||||
// ---------------------------------------------------------------------------
|
||||
function HeavyHittersTable({ heavyHitters }) {
|
||||
function HeavyHittersTable({ heavyHitters, onMetadataUpdate }) {
|
||||
if (!heavyHitters || heavyHitters.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '1.5rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
@@ -244,10 +323,20 @@ function HeavyHittersTable({ heavyHitters }) {
|
||||
{row.non_compliant}
|
||||
</td>
|
||||
<td style={cellStyle}>
|
||||
{row.compliance_date || ''}
|
||||
<EditableCell
|
||||
value={row.compliance_date || ''}
|
||||
type="text"
|
||||
placeholder="e.g. Q3 2026"
|
||||
onSave={(val) => onMetadataUpdate && onMetadataUpdate(row.vertical || row.team, { compliance_date: val || null })}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ ...cellStyle, color: '#94A3B8', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{row.notes || ''}
|
||||
<EditableCell
|
||||
value={row.notes || ''}
|
||||
type="text"
|
||||
placeholder="Add notes"
|
||||
onSave={(val) => onMetadataUpdate && onMetadataUpdate(row.vertical || row.team, { notes: val })}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -260,7 +349,7 @@ function HeavyHittersTable({ heavyHitters }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vertical Breakdown Table (Task 15)
|
||||
// ---------------------------------------------------------------------------
|
||||
function VerticalBreakdownTable({ verticalBreakdown }) {
|
||||
function VerticalBreakdownTable({ verticalBreakdown, onMetadataUpdate }) {
|
||||
if (!verticalBreakdown || verticalBreakdown.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '1.5rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
@@ -357,10 +446,21 @@ function VerticalBreakdownTable({ verticalBreakdown }) {
|
||||
{row.blockers || 0}
|
||||
</td>
|
||||
<td style={{ ...cellStyle, textAlign: 'right', color: '#94A3B8' }}>
|
||||
{row.risk_acceptances || 0}
|
||||
<EditableCell
|
||||
value={row.risk_acceptances || 0}
|
||||
type="number"
|
||||
placeholder="0"
|
||||
onSave={(val) => onMetadataUpdate && onMetadataUpdate(row.vertical || row.team, { risk_acceptances: val })}
|
||||
style={{ textAlign: 'right', width: '50px' }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ ...cellStyle, color: '#94A3B8', maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{row.notes || ''}
|
||||
<EditableCell
|
||||
value={row.notes || ''}
|
||||
type="text"
|
||||
placeholder="Add notes"
|
||||
onSave={(val) => onMetadataUpdate && onMetadataUpdate(row.vertical || row.team, { notes: val })}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -381,6 +481,40 @@ export default function VCLReportPage() {
|
||||
const [error, setError] = useState(null);
|
||||
const [showBulkUpload, setShowBulkUpload] = useState(false);
|
||||
|
||||
const handleMetadataUpdate = async (team, fields) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/compliance/vcl/vertical-metadata/${encodeURIComponent(team)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
console.error('Failed to update metadata:', data.error || res.statusText);
|
||||
return;
|
||||
}
|
||||
// Optimistically update local state
|
||||
setStats(prev => {
|
||||
if (!prev) return prev;
|
||||
const updated = { ...prev };
|
||||
if (updated.heavy_hitters) {
|
||||
updated.heavy_hitters = updated.heavy_hitters.map(hh =>
|
||||
(hh.vertical === team || hh.team === team) ? { ...hh, ...fields } : hh
|
||||
);
|
||||
}
|
||||
if (updated.vertical_breakdown) {
|
||||
updated.vertical_breakdown = updated.vertical_breakdown.map(vb =>
|
||||
(vb.vertical === team || vb.team === team) ? { ...vb, ...fields } : vb
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Metadata update error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
@@ -467,10 +601,10 @@ export default function VCLReportPage() {
|
||||
</div>
|
||||
|
||||
{/* Heavy Hitters */}
|
||||
<HeavyHittersTable heavyHitters={stats?.heavy_hitters} />
|
||||
<HeavyHittersTable heavyHitters={stats?.heavy_hitters} onMetadataUpdate={handleMetadataUpdate} />
|
||||
|
||||
{/* Vertical Breakdown */}
|
||||
<VerticalBreakdownTable verticalBreakdown={stats?.vertical_breakdown} />
|
||||
<VerticalBreakdownTable verticalBreakdown={stats?.vertical_breakdown} onMetadataUpdate={handleMetadataUpdate} />
|
||||
|
||||
{/* Bulk Upload Modal */}
|
||||
{showBulkUpload && (
|
||||
|
||||
Reference in New Issue
Block a user