feat(insights): collapsible Alert categories + Watch list accordions
The PM Insights "Alert categories" and "Watch list" sections are now accordions: each heading is a button (chevron + category-count badge, aria-expanded/aria-controls) that collapses/expands its card grid. Both default open; the per-card ticket expand and the Per-PM roll-up table are unchanged. - Section accordion component + sections state in Insights/index.tsx - ChevronIcon that rotates with the section's open state - .grid[hidden] rule so a collapsed grid actually hides (beats .grid on specificity + source order) - bump 2.3.0 -> 2.3.1 (root + client), CHANGELOG [2.3.1] - engineer artifact + INDEX line; verifier-log PASS entry verifier PASS (verifier-log 2026-08-29 12:07): reproduced tsc + vite build; confirmed the [hidden] collapse in the compiled CSS; no per-card regression; no new dependency. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VEsaHQx8cXr1hFrKU42UK6
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "forge-client",
|
||||
"private": true,
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -60,6 +60,14 @@ export function CloseIcon({ size = 18 }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpreadsheetIcon({ size = 15 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
|
||||
@@ -17,12 +17,49 @@
|
||||
margin-top: $s-4;
|
||||
}
|
||||
|
||||
.section { display: flex; flex-direction: column; gap: $s-3; }
|
||||
.sectionHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
width: 100%;
|
||||
margin-top: $s-4;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
&:hover { color: var(--text); }
|
||||
}
|
||||
.chev {
|
||||
display: inline-flex;
|
||||
color: var(--text-dim);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.sectionHead[aria-expanded='true'] .chev { transform: rotate(90deg); }
|
||||
.sectionHead:hover .chev { color: var(--text-muted); }
|
||||
.sectionTitle { flex: 0 0 auto; }
|
||||
.sectionCount {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: $s-3;
|
||||
align-items: start;
|
||||
}
|
||||
.grid[hidden] { display: none; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import KpiTile from '../../components/charts/KpiTile';
|
||||
import { ExternalIcon } from '../../components/icons';
|
||||
import { ExternalIcon, ChevronIcon } from '../../components/icons';
|
||||
import { getInsights } from '../../services/analytics.service';
|
||||
import type { InsightsResponse, AlertGroup, InsightTicket } from '../../types/analytics.types';
|
||||
import styles from './Insights.module.scss';
|
||||
@@ -15,6 +15,8 @@ export default function Insights({ refreshKey }: { refreshKey: number }) {
|
||||
const [data, setData] = useState<InsightsResponse | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [sections, setSections] = useState({ alerts: true, watch: true });
|
||||
const toggleSection = (key: 'alerts' | 'watch') => setSections(s => ({ ...s, [key]: !s[key] }));
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -40,15 +42,13 @@ export default function Insights({ refreshKey }: { refreshKey: number }) {
|
||||
<KpiTile label="PO escalations" value={`${data.waitingPo.levels.l1}·${data.waitingPo.levels.l2}·${data.waitingPo.levels.l3}`} tone="warning" hint="L1 · L2 · L3" />
|
||||
</div>
|
||||
|
||||
<h2 className={styles.h2}>Alert categories</h2>
|
||||
<div className={styles.grid}>
|
||||
<Section title="Alert categories" count={alertGroups.length} open={sections.alerts} onToggle={() => toggleSection('alerts')}>
|
||||
{alertGroups.map(g => <AlertCard key={g.key} g={g} open={open === g.key} onToggle={() => setOpen(open === g.key ? null : g.key)} showProblem />)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<h2 className={styles.h2}>Watch list</h2>
|
||||
<div className={styles.grid}>
|
||||
<Section title="Watch list" count={infoGroups.length} open={sections.watch} onToggle={() => toggleSection('watch')}>
|
||||
{infoGroups.map(g => <AlertCard key={g.key} g={g} open={open === g.key} onToggle={() => setOpen(open === g.key ? null : g.key)} />)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<h2 className={styles.h2}>Per-PM roll-up</h2>
|
||||
<div className={styles.pmCard}>
|
||||
@@ -73,6 +73,20 @@ export default function Insights({ refreshKey }: { refreshKey: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, count, open, onToggle, children }: { title: string; count: number; open: boolean; onToggle: () => void; children: ReactNode }) {
|
||||
const bodyId = `insights-${title.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<button className={styles.sectionHead} onClick={onToggle} aria-expanded={open} aria-controls={bodyId}>
|
||||
<span className={styles.chev}><ChevronIcon size={14} /></span>
|
||||
<span className={styles.sectionTitle}>{title}</span>
|
||||
<span className={styles.sectionCount}>{count}</span>
|
||||
</button>
|
||||
<div id={bodyId} className={styles.grid} hidden={!open}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertCard({ g, open, onToggle, showProblem }: { g: AlertGroup; open: boolean; onToggle: () => void; showProblem?: boolean }) {
|
||||
const shown = showProblem ? g.tickets.filter((_, i) => i < g.problematic || open) : g.tickets;
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user