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:
@@ -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' });
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<React.Fragment key={m.metric_id}>
|
||||||
|
{/* Primary metric row */}
|
||||||
<tr
|
<tr
|
||||||
key={i}
|
|
||||||
onClick={() => onSelectMetric(m.metric_id)}
|
|
||||||
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' }}>
|
||||||
|
{hasSubTeams && !teamFilter && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggleMetricExpand(m.metric_id); }}
|
||||||
|
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', fontSize: '0.75rem', padding: '0.2rem' }}
|
||||||
|
title={isExpanded ? 'Collapse sub-teams' : 'Expand sub-teams'}
|
||||||
|
>
|
||||||
|
{isExpanded ? '▾' : '▸'}
|
||||||
|
</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', 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' }}>{m.category}</td>
|
||||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{m.compliant}</td>
|
<td style={{ ...TD_STYLE, fontSize: '0.7rem', color: TEAL }}>{teamFilter || ''}</td>
|
||||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{m.non_compliant}</td>
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{(displayRow.compliant || 0).toLocaleString()}</td>
|
||||||
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{m.total}</td>
|
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{(displayRow.non_compliant || 0).toLocaleString()}</td>
|
||||||
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: pctColor }}>{Number(m.compliance_pct).toFixed(1)}%</td>
|
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{(displayRow.total || 0).toLocaleString()}</td>
|
||||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#64748B' }}>{Number(m.target).toFixed(0)}%</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>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user