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