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

@@ -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>