From 61d7e00d4f4d10c9b030966505711e0f481e1203 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Thu, 14 May 2026 12:27:46 -0600 Subject: [PATCH] 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. --- backend/routes/vclMultiVertical.js | 80 ++++++++-- .../src/components/pages/CCPMetricsPage.js | 150 +++++++++++++++--- 2 files changed, 201 insertions(+), 29 deletions(-) diff --git a/backend/routes/vclMultiVertical.js b/backend/routes/vclMultiVertical.js index d52db95..5450908 100644 --- a/backend/routes/vclMultiVertical.js +++ b/backend/routes/vclMultiVertical.js @@ -652,6 +652,7 @@ function createVCLMultiVerticalRouter(upload) { /** * GET /vertical/:code/metrics * 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 * @route /vertical/:code/metrics @@ -671,7 +672,14 @@ function createVCLMultiVerticalRouter(upload) { * total: number, * compliance_pct: number, * target: number, - * status: string + * status: string, + * sub_teams: Array<{ + * team: string, + * non_compliant: number, + * compliant: number, + * total: number, + * compliance_pct: number + * }> * }>, * categories: Array<{ * category: string, @@ -679,7 +687,8 @@ function createVCLMultiVerticalRouter(upload) { * compliant: number, * total: number, * compliance_pct: number - * }> + * }>, + * teams: string[] * } * @response 400 { error: string } — invalid vertical code * @response 500 { error: string } @@ -695,24 +704,74 @@ function createVCLMultiVerticalRouter(upload) { [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; - // Get per-metric summary data - const { rows: metrics } = await pool.query( + // Get per-metric summary data (all rows including sub-teams) + const { rows: allRows } = await pool.query( `SELECT metric_id, metric_desc, category, team, priority, non_compliant, compliant, total, compliance_pct, target, status FROM vcl_multi_vertical_summary WHERE upload_id = $1 AND vertical = $2 - ORDER BY category, metric_id`, + ORDER BY category, metric_id, team`, [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 = {}; for (const m of metrics) { - if (!m.team || !m.team.startsWith('ALL:')) continue; const cat = m.category || 'Other'; if (!categoryMap[cat]) categoryMap[cat] = { category: cat, non_compliant: 0, compliant: 0, total: 0 }; 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, })); - 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) { console.error('[VCL Multi] GET /vertical/:code/metrics error:', err.message); res.status(500).json({ error: 'Database error' }); diff --git a/frontend/src/components/pages/CCPMetricsPage.js b/frontend/src/components/pages/CCPMetricsPage.js index 9103a21..2ac5fa6 100644 --- a/frontend/src/components/pages/CCPMetricsPage.js +++ b/frontend/src/components/pages/CCPMetricsPage.js @@ -208,8 +208,11 @@ function VerticalTable({ breakdown, onSelectVertical }) { function VerticalDetailView({ vertical, onBack, onSelectMetric }) { const [metrics, setMetrics] = useState(null); const [categories, setCategories] = useState(null); + const [teams, setTeams] = useState([]); const [burndown, setBurndown] = useState(null); 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). useEffect(() => { @@ -220,13 +223,28 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) { ]).then(([metricsData, burndownData]) => { setMetrics(metricsData.metrics || []); setCategories(metricsData.categories || []); + setTeams(metricsData.teams || []); setBurndown(burndownData); setLoading(false); }).catch(() => setLoading(false)); }, [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
Loading...
; + // 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 (
+ {teams.map(t => ( + + ))} +
+ + )} + + {/* Metrics table with expandable sub-team rows */}
- Metrics + Metrics {teamFilter && — {teamFilter}}
+ + @@ -322,25 +384,73 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) { - {metrics && metrics.map((m, i) => { - const pctColor = m.compliance_pct >= m.target ? '#10B981' : m.compliance_pct >= (m.target * 0.85) ? '#F59E0B' : '#EF4444'; + {displayMetrics && displayMetrics.map((m) => { + 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 ( - onSelectMetric(m.metric_id)} - style={{ cursor: 'pointer', transition: 'background 0.15s' }} - onMouseEnter={e => e.currentTarget.style.background = 'rgba(167, 139, 250, 0.06)'} - onMouseLeave={e => e.currentTarget.style.background = 'transparent'} - > - - - - - - - - - + + {/* Primary metric row */} + e.currentTarget.style.background = 'rgba(167, 139, 250, 0.06)'} + onMouseLeave={e => e.currentTarget.style.background = 'transparent'} + > + + + + + + + + + + + + + {/* 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 ( + + + + + + + + + + + + + ); + })} + ); })}
Metric Description Category{teamFilter ? 'Team' : ''} Compliant Non-Compliant Total
{m.metric_id}{m.metric_desc}{m.category}{m.compliant}{m.non_compliant}{m.total}{Number(m.compliance_pct).toFixed(1)}%{Number(m.target).toFixed(0)}%
+ {hasSubTeams && !teamFilter && ( + + )} + onSelectMetric(m.metric_id)} + > + {m.metric_id} + {m.metric_desc}{m.category}{teamFilter || ''}{(displayRow.compliant || 0).toLocaleString()}{(displayRow.non_compliant || 0).toLocaleString()}{(displayRow.total || 0).toLocaleString()}{(displayPct * 100).toFixed(1)}%{(targetVal * 100).toFixed(0)}%
{st.team}{(st.compliant || 0).toLocaleString()}{(st.non_compliant || 0).toLocaleString()}{(st.total || 0).toLocaleString()}{(stPct * 100).toFixed(1)}%