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