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:
@@ -796,16 +796,19 @@ function createVCLMultiVerticalRouter(upload) {
|
|||||||
/**
|
/**
|
||||||
* GET /vertical/:code/metric/:metricId/devices
|
* GET /vertical/:code/metric/:metricId/devices
|
||||||
* Returns the list of non-compliant devices for a specific vertical + metric.
|
* Returns the list of non-compliant devices for a specific vertical + metric.
|
||||||
|
* Optionally filters by team when the query parameter is provided.
|
||||||
*
|
*
|
||||||
* @method GET
|
* @method GET
|
||||||
* @route /vertical/:code/metric/:metricId/devices
|
* @route /vertical/:code/metric/:metricId/devices
|
||||||
* @param {string} code — vertical code (e.g., "NTS_AEO")
|
* @param {string} code — vertical code (e.g., "NTS_AEO")
|
||||||
* @param {string} metricId — metric identifier (e.g., "VM-001")
|
* @param {string} metricId — metric identifier (e.g., "VM-001")
|
||||||
|
* @query {string} [team] — optional team name to filter devices (e.g., "STEAM", "ACCESS-ENG")
|
||||||
*
|
*
|
||||||
* @response 200
|
* @response 200
|
||||||
* {
|
* {
|
||||||
* vertical: string,
|
* vertical: string,
|
||||||
* metric_id: string,
|
* metric_id: string,
|
||||||
|
* team: string|null,
|
||||||
* devices: Array<{
|
* devices: Array<{
|
||||||
* hostname: string,
|
* hostname: string,
|
||||||
* ip_address: string,
|
* ip_address: string,
|
||||||
@@ -828,19 +831,26 @@ function createVCLMultiVerticalRouter(upload) {
|
|||||||
if (!metricId || metricId.length > 50) return res.status(400).json({ error: 'Invalid metric ID' });
|
if (!metricId || metricId.length > 50) return res.status(400).json({ error: 'Invalid metric ID' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const team = req.query.team || null;
|
||||||
`SELECT ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.seen_count,
|
let query = `SELECT ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.seen_count,
|
||||||
ci.resolution_date, ci.remediation_plan,
|
ci.resolution_date, ci.remediation_plan,
|
||||||
fu.report_date AS first_seen, lu.report_date AS last_seen
|
fu.report_date AS first_seen, lu.report_date AS last_seen
|
||||||
FROM compliance_items ci
|
FROM compliance_items ci
|
||||||
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||||
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||||
WHERE ci.vertical = $1 AND ci.metric_id = $2 AND ci.status = 'active'
|
WHERE ci.vertical = $1 AND ci.metric_id = $2 AND ci.status = 'active'`;
|
||||||
ORDER BY ci.hostname`,
|
const params = [vertical, metricId];
|
||||||
[vertical, metricId]
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ vertical, metric_id: metricId, devices: rows });
|
if (team) {
|
||||||
|
query += ` AND ci.team = $3`;
|
||||||
|
params.push(team);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY ci.hostname`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, params);
|
||||||
|
|
||||||
|
res.json({ vertical, metric_id: metricId, team: team || null, devices: rows });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[VCL Multi] GET /vertical/:code/metric/:metricId/devices error:', err.message);
|
console.error('[VCL Multi] GET /vertical/:code/metric/:metricId/devices error:', err.message);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
|||||||
@@ -110,8 +110,15 @@ Executive Overview (all verticals aggregated)
|
|||||||
│ │ │ ├── └ INTELDEV: 233 compliant, 11 NC, 244 total — 95.0%
|
│ │ │ ├── └ INTELDEV: 233 compliant, 11 NC, 244 total — 95.0%
|
||||||
│ │ │ └── └ STEAM: 123 compliant, 4 NC, 127 total — 97.0%
|
│ │ │ └── └ STEAM: 123 compliant, 4 NC, 127 total — 97.0%
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── ▸ 7.1.1 (Logging & Monitoring) — 72.0% — 149 NC → click metric
|
│ │ ├── Click metric ID → Metric Sub-Team View
|
||||||
│ │ │ └── Device list: hostname, IP, type, team, seen_count, resolution_date
|
│ │ │ ├── Stats: total 66,176 | compliant 64,414 | NC 1,762 | 97% | target 80%
|
||||||
|
│ │ │ └── Sub-Team Table:
|
||||||
|
│ │ │ ├── ACCESS-ENG — 8 total — 88.0% → click
|
||||||
|
│ │ │ │ └── Device list (filtered to ACCESS-ENG)
|
||||||
|
│ │ │ ├── ACCESS-OPS — 65,797 total — 97.0% → click
|
||||||
|
│ │ │ │ └── Device list (filtered to ACCESS-OPS)
|
||||||
|
│ │ │ ├── INTELDEV — 244 total — 95.0% → click
|
||||||
|
│ │ │ └── STEAM — 127 total — 97.0% → click
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ │
|
│ │
|
||||||
│ └── Burndown: blockers, with dates, projected clear date
|
│ └── Burndown: blockers, with dates, projected clear date
|
||||||
@@ -197,7 +204,7 @@ Some metrics have a team value of `(Other)` in the Summary sheet. This represent
|
|||||||
|
|
||||||
### Device-Level Drill-Down
|
### Device-Level Drill-Down
|
||||||
|
|
||||||
Clicking a metric ID navigates to the device list — individual non-compliant hostnames for that vertical + metric combination. This data comes from the detail sheets (not the Summary sheet) and shows:
|
Clicking a sub-team row in the metric sub-team view navigates to the device list — individual non-compliant hostnames for that vertical + metric + team combination. The device list is filtered to only show devices belonging to the selected team. This data comes from the detail sheets (not the Summary sheet) and shows:
|
||||||
|
|
||||||
- Hostname, IP address, device type, team
|
- Hostname, IP address, device type, team
|
||||||
- Seen count (how many consecutive uploads this device has been non-compliant)
|
- Seen count (how many consecutive uploads this device has been non-compliant)
|
||||||
@@ -205,6 +212,14 @@ Clicking a metric ID navigates to the device list — individual non-compliant h
|
|||||||
- Resolution date (if set)
|
- Resolution date (if set)
|
||||||
- Remediation plan (if documented)
|
- Remediation plan (if documented)
|
||||||
|
|
||||||
|
If a metric has no sub-team breakdown (e.g., only an "(Other)" team), a "View All Devices" button is shown instead, which loads the full unfiltered device list for that metric.
|
||||||
|
|
||||||
|
The full navigation path is:
|
||||||
|
|
||||||
|
```
|
||||||
|
Overview → Vertical → Metric (sub-team totals) → Team (device list)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Burndown Forecast
|
## Burndown Forecast
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
|
|||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}
|
style={{ ...TD_STYLE, fontWeight: '600', color: PURPLE }}
|
||||||
onClick={() => onSelectMetric(m.metric_id)}
|
onClick={() => onSelectMetric(m.metric_id, m)}
|
||||||
>
|
>
|
||||||
{m.metric_id}
|
{m.metric_id}
|
||||||
</td>
|
</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 }) {
|
function MetricSubTeamView({ vertical, metricId, metricData, onBack, onSelectTeam }) {
|
||||||
const [devices, setDevices] = useState(null);
|
// metricData contains the metric's sub_teams from the parent view
|
||||||
const [loading, setLoading] = useState(true);
|
const pctColor = (pct, target) => {
|
||||||
// ⚠️ 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).
|
const p = Number(pct || 0);
|
||||||
|
const t = Number(target || 0);
|
||||||
|
return p >= t ? '#10B981' : p >= t * 0.85 ? '#F59E0B' : '#EF4444';
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const targetVal = Number(metricData?.target || 0);
|
||||||
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>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -487,8 +482,126 @@ function MetricDeviceList({ vertical, metricId, onBack }) {
|
|||||||
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Metrics
|
<ChevronLeft style={{ width: '16px', height: '16px' }} /> Back to Metrics
|
||||||
</button>
|
</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' }}>
|
<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>
|
</h3>
|
||||||
|
|
||||||
<div style={CARD_STYLE}>
|
<div style={CARD_STYLE}>
|
||||||
@@ -611,6 +724,7 @@ function DataManagementPanel({ onClose, onDataChanged }) {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
<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>
|
<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 */}
|
||||||
|
{/* ⚠️ CONVENTION: Use lucide-react <X /> icon instead of raw Unicode character */}
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>✕</button>
|
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -706,6 +820,8 @@ export default function CCPMetricsPage() {
|
|||||||
// Drill-down state
|
// Drill-down state
|
||||||
const [selectedVertical, setSelectedVertical] = useState(null);
|
const [selectedVertical, setSelectedVertical] = useState(null);
|
||||||
const [selectedMetric, setSelectedMetric] = useState(null);
|
const [selectedMetric, setSelectedMetric] = useState(null);
|
||||||
|
const [selectedMetricData, setSelectedMetricData] = useState(null);
|
||||||
|
const [selectedTeam, setSelectedTeam] = useState(null);
|
||||||
|
|
||||||
const fetchData = useCallback(() => {
|
const fetchData = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -731,13 +847,28 @@ export default function CCPMetricsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Render drill-down views
|
// Render drill-down views
|
||||||
if (selectedMetric && selectedVertical) {
|
if (selectedTeam !== null && selectedMetric && selectedVertical) {
|
||||||
return (
|
return (
|
||||||
<div style={PAGE_STYLE}>
|
<div style={PAGE_STYLE}>
|
||||||
<MetricDeviceList
|
<MetricDeviceList
|
||||||
vertical={selectedVertical}
|
vertical={selectedVertical}
|
||||||
metricId={selectedMetric}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -749,7 +880,7 @@ export default function CCPMetricsPage() {
|
|||||||
<VerticalDetailView
|
<VerticalDetailView
|
||||||
vertical={selectedVertical}
|
vertical={selectedVertical}
|
||||||
onBack={() => setSelectedVertical(null)}
|
onBack={() => setSelectedVertical(null)}
|
||||||
onSelectMetric={(metricId) => setSelectedMetric(metricId)}
|
onSelectMetric={(metricId, metricData) => { setSelectedMetric(metricId); setSelectedMetricData(metricData); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user