Add metric sub-team intermediate drill-down view
Clicking a metric now shows a sub-team breakdown page with totals per team (compliant, non-compliant, total, %) instead of jumping directly to a flat device list. Clicking a sub-team then shows the device list filtered to that team only. Navigation flow: Overview → Vertical → Metric (sub-team totals) → Team (devices) Backend: added optional ?team= query param to the device list endpoint for filtered queries. Frontend: added MetricSubTeamView component with metric-level stats bar and clickable sub-team table. Updated navigation state to include selectedTeam. Also updated design brief to reflect the new drill-down hierarchy.
This commit is contained in:
@@ -417,7 +417,7 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
|
||||
</td>
|
||||
<td
|
||||
style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}
|
||||
onClick={() => onSelectMetric(m.metric_id)}
|
||||
onClick={() => onSelectMetric(m.metric_id, m)}
|
||||
>
|
||||
{m.metric_id}
|
||||
</td>
|
||||
@@ -461,22 +461,17 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric Device List (deepest drill-down)
|
||||
// Metric Sub-Team View (intermediate drill-down: metric → sub-teams)
|
||||
// ---------------------------------------------------------------------------
|
||||
function MetricDeviceList({ vertical, metricId, onBack }) {
|
||||
const [devices, setDevices] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// ⚠️ 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).
|
||||
function MetricSubTeamView({ vertical, metricId, metricData, onBack, onSelectTeam }) {
|
||||
// metricData contains the metric's sub_teams from the parent view
|
||||
const pctColor = (pct, target) => {
|
||||
const p = Number(pct || 0);
|
||||
const t = Number(target || 0);
|
||||
return p >= t ? '#10B981' : p >= t * 0.85 ? '#F59E0B' : '#EF4444';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}/metric/${encodeURIComponent(metricId)}/devices`, { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(data => { setDevices(data.devices || []); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, [vertical, metricId]);
|
||||
|
||||
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
|
||||
const targetVal = Number(metricData?.target || 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -487,8 +482,126 @@ function MetricDeviceList({ vertical, metricId, onBack }) {
|
||||
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Metrics
|
||||
</button>
|
||||
|
||||
<h3 style={{ fontSize: '1.1rem', color: '#E2E8F0', marginBottom: '0.25rem', fontWeight: '700' }}>
|
||||
{vertical} / Metric {metricId}
|
||||
</h3>
|
||||
{metricData?.metric_desc && (
|
||||
<p style={{ fontSize: '0.75rem', color: '#94A3B8', margin: '0 0 1.5rem 0' }}>{metricData.metric_desc}</p>
|
||||
)}
|
||||
|
||||
{/* Metric rollup stats */}
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<div style={STAT_CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Total</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#E2E8F0' }}>{(metricData?.total || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
<div style={STAT_CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Compliant</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#10B981' }}>{(metricData?.compliant || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
<div style={STAT_CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Non-Compliant</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: '#EF4444' }}>{(metricData?.non_compliant || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
<div style={STAT_CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Compliance</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: pctColor(metricData?.compliance_pct, metricData?.target) }}>
|
||||
{(Number(metricData?.compliance_pct || 0) * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div style={STAT_CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', textTransform: 'uppercase', marginBottom: '0.5rem' }}>Target</div>
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '700', color: PURPLE }}>{(targetVal * 100).toFixed(0)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-team breakdown table */}
|
||||
<div style={CARD_STYLE}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
Sub-Team Breakdown
|
||||
</div>
|
||||
{metricData?.sub_teams && metricData.sub_teams.length > 0 ? (
|
||||
<table style={TABLE_STYLE}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>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>
|
||||
<th style={{ ...TH_STYLE, textAlign: 'right' }}>Compliance %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metricData.sub_teams.map(st => {
|
||||
const stPct = Number(st.compliance_pct || 0);
|
||||
const stPctColor = pctColor(st.compliance_pct, metricData.target);
|
||||
return (
|
||||
<tr
|
||||
key={st.team}
|
||||
onClick={() => onSelectTeam(st.team)}
|
||||
style={{ cursor: 'pointer', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(20, 184, 166, 0.08)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<td style={{ ...TD_STYLE, fontWeight: '600', color: TEAL }}>{st.team}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#10B981' }}>{(st.compliant || 0).toLocaleString()}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', color: '#EF4444' }}>{(st.non_compliant || 0).toLocaleString()}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right' }}>{(st.total || 0).toLocaleString()}</td>
|
||||
<td style={{ ...TD_STYLE, textAlign: 'right', fontWeight: '700', color: stPctColor }}>{(stPct * 100).toFixed(1)}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div style={{ color: '#64748B', fontSize: '0.8rem', padding: '1rem', textAlign: 'center' }}>
|
||||
No sub-team breakdown available for this metric.
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button
|
||||
onClick={() => onSelectTeam(null)}
|
||||
style={{ padding: '0.5rem 1rem', background: `${PURPLE}20`, border: `1px solid ${PURPLE}60`, borderRadius: '0.5rem', color: PURPLE, fontSize: '0.75rem', cursor: 'pointer', fontWeight: '600' }}
|
||||
>
|
||||
View All Devices
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric Device List (deepest drill-down — filtered by team)
|
||||
// ---------------------------------------------------------------------------
|
||||
function MetricDeviceList({ vertical, metricId, team, onBack }) {
|
||||
const [devices, setDevices] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// ⚠️ CONVENTION: Missing error state — .catch() below silently swallows errors without displaying them to the user. Add an error state and render an error message.
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
let url = `${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}/metric/${encodeURIComponent(metricId)}/devices`;
|
||||
if (team) url += `?team=${encodeURIComponent(team)}`;
|
||||
fetch(url, { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(data => { setDevices(data.devices || []); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, [vertical, metricId, team]);
|
||||
|
||||
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{ background: 'none', border: 'none', color: PURPLE, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.8rem', marginBottom: '1.5rem', padding: 0 }}
|
||||
>
|
||||
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Sub-Teams
|
||||
</button>
|
||||
|
||||
<h3 style={{ fontSize: '1rem', color: '#E2E8F0', marginBottom: '1rem' }}>
|
||||
{vertical} / Metric {metricId} — {devices ? devices.length : 0} non-compliant devices
|
||||
{vertical} / Metric {metricId}{team ? ` / ${team}` : ''} — {devices ? devices.length : 0} non-compliant devices
|
||||
</h3>
|
||||
|
||||
<div style={CARD_STYLE}>
|
||||
@@ -611,6 +724,7 @@ function DataManagementPanel({ onClose, onDataChanged }) {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: '700', color: '#E2E8F0', margin: 0 }}>Manage Data</h2>
|
||||
{/* ⚠️ CONVENTION: Use lucide-react <X /> icon instead of raw Unicode character */}
|
||||
{/* ⚠️ CONVENTION: Use lucide-react <X /> icon instead of raw Unicode character */}
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>✕</button>
|
||||
</div>
|
||||
|
||||
@@ -706,6 +820,8 @@ export default function CCPMetricsPage() {
|
||||
// Drill-down state
|
||||
const [selectedVertical, setSelectedVertical] = useState(null);
|
||||
const [selectedMetric, setSelectedMetric] = useState(null);
|
||||
const [selectedMetricData, setSelectedMetricData] = useState(null);
|
||||
const [selectedTeam, setSelectedTeam] = useState(null);
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -731,13 +847,28 @@ export default function CCPMetricsPage() {
|
||||
};
|
||||
|
||||
// Render drill-down views
|
||||
if (selectedMetric && selectedVertical) {
|
||||
if (selectedTeam !== null && selectedMetric && selectedVertical) {
|
||||
return (
|
||||
<div style={PAGE_STYLE}>
|
||||
<MetricDeviceList
|
||||
vertical={selectedVertical}
|
||||
metricId={selectedMetric}
|
||||
onBack={() => setSelectedMetric(null)}
|
||||
team={selectedTeam}
|
||||
onBack={() => setSelectedTeam(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedMetric && selectedVertical) {
|
||||
return (
|
||||
<div style={PAGE_STYLE}>
|
||||
<MetricSubTeamView
|
||||
vertical={selectedVertical}
|
||||
metricId={selectedMetric}
|
||||
metricData={selectedMetricData}
|
||||
onBack={() => { setSelectedMetric(null); setSelectedMetricData(null); }}
|
||||
onSelectTeam={(team) => setSelectedTeam(team)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -749,7 +880,7 @@ export default function CCPMetricsPage() {
|
||||
<VerticalDetailView
|
||||
vertical={selectedVertical}
|
||||
onBack={() => setSelectedVertical(null)}
|
||||
onSelectMetric={(metricId) => setSelectedMetric(metricId)}
|
||||
onSelectMetric={(metricId, metricData) => { setSelectedMetric(metricId); setSelectedMetricData(metricData); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user