Add sub-team level display to CCP Metrics vertical drill-down

Backend: restructured /vertical/:code/metrics endpoint to return metrics
with nested sub_teams arrays. Each metric now has the ALL: rollup as the
primary row and individual team breakdowns (ACCESS-OPS, STEAM, etc.) as
sub_teams. Also returns a teams array for the filter UI.

Frontend: VerticalDetailView now supports two interaction modes:
- Expand/collapse: click the arrow on any metric row to reveal sub-team
  breakdown inline (teal-highlighted rows beneath the parent)
- Team filter: click a team button to filter the entire table to show
  only that team's numbers per metric

Both modes avoid double-counting by using the ALL: rollup for totals
and only showing sub-team data as supplementary detail.
This commit is contained in:
Jordan Ramos
2026-05-14 12:27:46 -06:00
parent ebaf4cd18c
commit 61d7e00d4f
2 changed files with 201 additions and 29 deletions

View File

@@ -652,6 +652,7 @@ function createVCLMultiVerticalRouter(upload) {
/** /**
* GET /vertical/:code/metrics * GET /vertical/:code/metrics
* Returns per-metric breakdown for a specific vertical from the latest upload's summary data. * Returns per-metric breakdown for a specific vertical from the latest upload's summary data.
* Metrics are the "ALL:" rollup rows; each includes a sub_teams array with per-team numbers.
* *
* @method GET * @method GET
* @route /vertical/:code/metrics * @route /vertical/:code/metrics
@@ -671,7 +672,14 @@ function createVCLMultiVerticalRouter(upload) {
* total: number, * total: number,
* compliance_pct: number, * compliance_pct: number,
* target: number, * target: number,
* status: string * status: string,
* sub_teams: Array<{
* team: string,
* non_compliant: number,
* compliant: number,
* total: number,
* compliance_pct: number
* }>
* }>, * }>,
* categories: Array<{ * categories: Array<{
* category: string, * category: string,
@@ -679,7 +687,8 @@ function createVCLMultiVerticalRouter(upload) {
* compliant: number, * compliant: number,
* total: number, * total: number,
* compliance_pct: number * compliance_pct: number
* }> * }>,
* teams: string[]
* } * }
* @response 400 { error: string } — invalid vertical code * @response 400 { error: string } — invalid vertical code
* @response 500 { error: string } * @response 500 { error: string }
@@ -695,24 +704,74 @@ function createVCLMultiVerticalRouter(upload) {
[vertical] [vertical]
); );
if (latestUpload.length === 0) return res.json({ vertical, metrics: [], categories: [] }); if (latestUpload.length === 0) return res.json({ vertical, metrics: [], categories: [], teams: [] });
const uploadId = latestUpload[0].id; const uploadId = latestUpload[0].id;
// Get per-metric summary data // Get per-metric summary data (all rows including sub-teams)
const { rows: metrics } = await pool.query( const { rows: allRows } = await pool.query(
`SELECT metric_id, metric_desc, category, team, priority, `SELECT metric_id, metric_desc, category, team, priority,
non_compliant, compliant, total, compliance_pct, target, status non_compliant, compliant, total, compliance_pct, target, status
FROM vcl_multi_vertical_summary FROM vcl_multi_vertical_summary
WHERE upload_id = $1 AND vertical = $2 WHERE upload_id = $1 AND vertical = $2
ORDER BY category, metric_id`, ORDER BY category, metric_id, team`,
[uploadId, vertical] [uploadId, vertical]
); );
// Aggregate by category — only use "ALL:" rollup rows to avoid double-counting // Separate into rollup rows (ALL:) and sub-team rows
// metrics = rollup rows only (one per metric — used for the primary table)
// Each metric gets a sub_teams array with the team-level breakdown
const metricMap = {};
const teamSet = new Set();
for (const row of allRows) {
const isRollup = row.team && row.team.startsWith('ALL:');
const isOther = row.team === '(Other)';
if (isRollup) {
// Primary metric row
metricMap[row.metric_id] = {
metric_id: row.metric_id,
metric_desc: row.metric_desc,
category: row.category,
priority: row.priority,
non_compliant: row.non_compliant,
compliant: row.compliant,
total: row.total,
compliance_pct: row.compliance_pct,
target: row.target,
status: row.status,
team: row.team,
sub_teams: [],
};
} else if (!isOther) {
// Sub-team row (skip "(Other)" — it's a catch-all already in the rollup)
teamSet.add(row.team);
}
}
// Second pass: attach sub-team rows to their parent metric
for (const row of allRows) {
const isRollup = row.team && row.team.startsWith('ALL:');
const isOther = row.team === '(Other)';
if (isRollup || isOther) continue;
if (metricMap[row.metric_id]) {
metricMap[row.metric_id].sub_teams.push({
team: row.team,
non_compliant: row.non_compliant,
compliant: row.compliant,
total: row.total,
compliance_pct: row.compliance_pct,
});
}
}
const metrics = Object.values(metricMap);
// Aggregate by category — only use rollup rows to avoid double-counting
const categoryMap = {}; const categoryMap = {};
for (const m of metrics) { for (const m of metrics) {
if (!m.team || !m.team.startsWith('ALL:')) continue;
const cat = m.category || 'Other'; const cat = m.category || 'Other';
if (!categoryMap[cat]) categoryMap[cat] = { category: cat, non_compliant: 0, compliant: 0, total: 0 }; if (!categoryMap[cat]) categoryMap[cat] = { category: cat, non_compliant: 0, compliant: 0, total: 0 };
categoryMap[cat].non_compliant += m.non_compliant; categoryMap[cat].non_compliant += m.non_compliant;
@@ -724,7 +783,10 @@ function createVCLMultiVerticalRouter(upload) {
compliance_pct: c.total > 0 ? Math.round((c.compliant / c.total) * 100 * 10) / 10 : 0, compliance_pct: c.total > 0 ? Math.round((c.compliant / c.total) * 100 * 10) / 10 : 0,
})); }));
res.json({ vertical, metrics, categories }); // Return distinct team names for the vertical (useful for filtering)
const teams = [...teamSet].sort();
res.json({ vertical, metrics, categories, teams });
} catch (err) { } catch (err) {
console.error('[VCL Multi] GET /vertical/:code/metrics error:', err.message); console.error('[VCL Multi] GET /vertical/:code/metrics error:', err.message);
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: 'Database error' });

View File

@@ -208,8 +208,11 @@ function VerticalTable({ breakdown, onSelectVertical }) {
function VerticalDetailView({ vertical, onBack, onSelectMetric }) { function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
const [metrics, setMetrics] = useState(null); const [metrics, setMetrics] = useState(null);
const [categories, setCategories] = useState(null); const [categories, setCategories] = useState(null);
const [teams, setTeams] = useState([]);
const [burndown, setBurndown] = useState(null); const [burndown, setBurndown] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [expandedMetrics, setExpandedMetrics] = useState(new Set());
const [teamFilter, setTeamFilter] = useState(''); // '' = all teams (rollup view)
// ⚠️ CONVENTION: Missing error state — .catch() silently swallows errors without displaying them to the user. Add an error state and render an error message (see main CCPMetricsPage pattern). // ⚠️ CONVENTION: Missing error state — .catch() silently swallows errors without displaying them to the user. Add an error state and render an error message (see main CCPMetricsPage pattern).
useEffect(() => { useEffect(() => {
@@ -220,13 +223,28 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
]).then(([metricsData, burndownData]) => { ]).then(([metricsData, burndownData]) => {
setMetrics(metricsData.metrics || []); setMetrics(metricsData.metrics || []);
setCategories(metricsData.categories || []); setCategories(metricsData.categories || []);
setTeams(metricsData.teams || []);
setBurndown(burndownData); setBurndown(burndownData);
setLoading(false); setLoading(false);
}).catch(() => setLoading(false)); }).catch(() => setLoading(false));
}, [vertical]); }, [vertical]);
const toggleMetricExpand = (metricId) => {
setExpandedMetrics(prev => {
const next = new Set(prev);
if (next.has(metricId)) next.delete(metricId);
else next.add(metricId);
return next;
});
};
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>; if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
// Filter metrics by team if a team filter is active
const displayMetrics = teamFilter
? metrics.filter(m => m.sub_teams && m.sub_teams.some(st => st.team === teamFilter))
: metrics;
return ( return (
<div> <div>
<button <button
@@ -303,17 +321,61 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
</div> </div>
)} )}
{/* Metrics table */} {/* Team filter */}
{teams.length > 0 && (
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem', padding: '0.75rem 1.25rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Filter by Team:</span>
<button
onClick={() => setTeamFilter('')}
style={{
padding: '0.3rem 0.7rem',
background: !teamFilter ? PURPLE : 'transparent',
border: `1px solid ${!teamFilter ? PURPLE : 'rgba(255,255,255,0.15)'}`,
borderRadius: '0.375rem',
color: !teamFilter ? '#FFF' : '#94A3B8',
fontSize: '0.7rem',
cursor: 'pointer',
fontWeight: !teamFilter ? '600' : '400',
}}
>
All (Rollup)
</button>
{teams.map(t => (
<button
key={t}
onClick={() => setTeamFilter(t === teamFilter ? '' : t)}
style={{
padding: '0.3rem 0.7rem',
background: teamFilter === t ? TEAL : 'transparent',
border: `1px solid ${teamFilter === t ? TEAL : 'rgba(255,255,255,0.15)'}`,
borderRadius: '0.375rem',
color: teamFilter === t ? '#FFF' : '#94A3B8',
fontSize: '0.7rem',
cursor: 'pointer',
fontWeight: teamFilter === t ? '600' : '400',
}}
>
{t}
</button>
))}
</div>
</div>
)}
{/* Metrics table with expandable sub-team rows */}
<div style={CARD_STYLE}> <div style={CARD_STYLE}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}> <div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
Metrics Metrics {teamFilter && <span style={{ color: TEAL, fontWeight: '600' }}> {teamFilter}</span>}
</div> </div>
<table style={TABLE_STYLE}> <table style={TABLE_STYLE}>
<thead> <thead>
<tr> <tr>
<th style={{ ...TH_STYLE, width: '30px' }}></th>
<th style={TH_STYLE}>Metric</th> <th style={TH_STYLE}>Metric</th>
<th style={TH_STYLE}>Description</th> <th style={TH_STYLE}>Description</th>
<th style={TH_STYLE}>Category</th> <th style={TH_STYLE}>Category</th>
<th style={TH_STYLE}>{teamFilter ? 'Team' : ''}</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Compliant</th> <th style={{ ...TH_STYLE, textAlign: 'right' }}>Compliant</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Non-Compliant</th> <th style={{ ...TH_STYLE, textAlign: 'right' }}>Non-Compliant</th>
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Total</th> <th style={{ ...TH_STYLE, textAlign: 'right' }}>Total</th>
@@ -322,25 +384,73 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{metrics && metrics.map((m, i) => { {displayMetrics && displayMetrics.map((m) => {
const pctColor = m.compliance_pct >= m.target ? '#10B981' : m.compliance_pct >= (m.target * 0.85) ? '#F59E0B' : '#EF4444'; const hasSubTeams = m.sub_teams && m.sub_teams.length > 0;
const isExpanded = expandedMetrics.has(m.metric_id);
// If team filter is active, show the filtered team's data instead of rollup
const displayRow = teamFilter && m.sub_teams
? m.sub_teams.find(st => st.team === teamFilter) || m
: m;
const displayPct = Number(displayRow.compliance_pct || 0);
const targetVal = Number(m.target || 0);
const pctColor = displayPct >= targetVal ? '#10B981' : displayPct >= (targetVal * 0.85) ? '#F59E0B' : '#EF4444';
return ( return (
<tr <React.Fragment key={m.metric_id}>
key={i} {/* Primary metric row */}
onClick={() => onSelectMetric(m.metric_id)} <tr
style={{ cursor: 'pointer', transition: 'background 0.15s' }} style={{ cursor: 'pointer', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(167, 139, 250, 0.06)'} onMouseEnter={e => e.currentTarget.style.background = 'rgba(167, 139, 250, 0.06)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
> >
<td style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}>{m.metric_id}</td> <td style={{ ...TD_STYLE, padding: '0.5rem', textAlign: 'center' }}>
<td style={{ ...TD_STYLE, fontSize: '0.7rem', color: '#94A3B8', maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.metric_desc}</td> {hasSubTeams && !teamFilter && (
<td style={{ ...TD_STYLE, fontSize: '0.7rem' }}>{m.category}</td> <button
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{m.compliant}</td> onClick={(e) => { e.stopPropagation(); toggleMetricExpand(m.metric_id); }}
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{m.non_compliant}</td> style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', fontSize: '0.75rem', padding: '0.2rem' }}
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{m.total}</td> title={isExpanded ? 'Collapse sub-teams' : 'Expand sub-teams'}
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: pctColor }}>{Number(m.compliance_pct).toFixed(1)}%</td> >
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#64748B' }}>{Number(m.target).toFixed(0)}%</td> {isExpanded ? '▾' : '▸'}
</tr> </button>
)}
</td>
<td
style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}
onClick={() => onSelectMetric(m.metric_id)}
>
{m.metric_id}
</td>
<td style={{ ...TD_STYLE, fontSize: '0.7rem', color: '#94A3B8', maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.metric_desc}</td>
<td style={{ ...TD_STYLE, fontSize: '0.7rem' }}>{m.category}</td>
<td style={{ ...TD_STYLE, fontSize: '0.7rem', color: TEAL }}>{teamFilter || ''}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{(displayRow.compliant || 0).toLocaleString()}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{(displayRow.non_compliant || 0).toLocaleString()}</td>
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{(displayRow.total || 0).toLocaleString()}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: pctColor }}>{(displayPct * 100).toFixed(1)}%</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#64748B' }}>{(targetVal * 100).toFixed(0)}%</td>
</tr>
{/* Expanded sub-team rows */}
{isExpanded && !teamFilter && hasSubTeams && m.sub_teams.map(st => {
const stPct = Number(st.compliance_pct || 0);
const stPctColor = stPct >= targetVal ? '#10B981' : stPct >= (targetVal * 0.85) ? '#F59E0B' : '#EF4444';
return (
<tr key={`${m.metric_id}-${st.team}`} style={{ background: 'rgba(20, 184, 166, 0.03)' }}>
<td style={{ ...TD_STYLE, padding: '0.4rem' }}></td>
<td style={{ ...TD_STYLE, paddingLeft: '1.5rem', fontSize: '0.7rem', color: '#64748B' }}></td>
<td style={{ ...TD_STYLE, fontSize: '0.7rem', color: '#94A3B8' }}></td>
<td style={{ ...TD_STYLE, fontSize: '0.7rem' }}></td>
<td style={{ ...TD_STYLE, fontSize: '0.75rem', color: TEAL, fontWeight: '500' }}>{st.team}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981', fontSize: '0.75rem' }}>{(st.compliant || 0).toLocaleString()}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444', fontSize: '0.75rem' }}>{(st.non_compliant || 0).toLocaleString()}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', fontSize: '0.75rem' }}>{(st.total || 0).toLocaleString()}</td>
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '600', color: stPctColor, fontSize: '0.75rem' }}>{(stPct * 100).toFixed(1)}%</td>
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#64748B', fontSize: '0.75rem' }}></td>
</tr>
);
})}
</React.Fragment>
); );
})} })}
</tbody> </tbody>