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
|
||||
* 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' });
|
||||
|
||||
@@ -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 <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 (
|
||||
<div>
|
||||
<button
|
||||
@@ -303,17 +321,61 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
|
||||
</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={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
Metrics
|
||||
Metrics {teamFilter && <span style={{ color: TEAL, fontWeight: '600' }}>— {teamFilter}</span>}
|
||||
</div>
|
||||
<table style={TABLE_STYLE}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...TH_STYLE, width: '30px' }}></th>
|
||||
<th style={TH_STYLE}>Metric</th>
|
||||
<th style={TH_STYLE}>Description</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' }}>Non-Compliant</th>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Total</th>
|
||||
@@ -322,25 +384,73 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr
|
||||
key={i}
|
||||
onClick={() => 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'}
|
||||
>
|
||||
<td style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}>{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, textAlign: 'right', color: '#10B981' }}>{m.compliant}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{m.non_compliant}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{m.total}</td>
|
||||
<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>
|
||||
</tr>
|
||||
<React.Fragment key={m.metric_id}>
|
||||
{/* Primary metric row */}
|
||||
<tr
|
||||
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'}
|
||||
>
|
||||
<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' }}>{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>
|
||||
|
||||
Reference in New Issue
Block a user