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:
Dmytro Tkachenko
2026-08-29 12:09:36 +03:00
parent 28d817ebe9
commit 3de14164fc
9 changed files with 112 additions and 10 deletions
+8
View File
@@ -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);
+22 -8
View File
@@ -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 (