Files
CASAN/packages/casan-control-panel/frontend/src/components/report/ReportPrimitives.tsx
T

252 lines
9.6 KiB
TypeScript

import type { ReactNode } from 'react';
type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
const TONE: Record<Tone, {
badge: string;
line: string;
value: string;
wash: string;
}> = {
neutral: {
badge: 'border-slate-600 bg-slate-800 text-slate-200',
line: 'bg-slate-500',
value: 'text-slate-950',
wash: 'border-slate-200 bg-white',
},
success: {
badge: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-200',
line: 'bg-emerald-500',
value: 'text-emerald-700',
wash: 'border-emerald-200 bg-emerald-50/45',
},
warning: {
badge: 'border-amber-400/40 bg-amber-400/10 text-amber-100',
line: 'bg-amber-500',
value: 'text-amber-700',
wash: 'border-amber-200 bg-amber-50/45',
},
danger: {
badge: 'border-rose-400/40 bg-rose-400/10 text-rose-100',
line: 'bg-rose-500',
value: 'text-rose-700',
wash: 'border-rose-200 bg-rose-50/45',
},
info: {
badge: 'border-cyan-400/40 bg-cyan-400/10 text-cyan-100',
line: 'bg-cyan-500',
value: 'text-cyan-700',
wash: 'border-cyan-200 bg-cyan-50/45',
},
};
export function reportTone(value: string): Tone {
const normalized = value.toLowerCase();
if (['pass', 'fresh', 'certified', 'success', 'verified'].includes(normalized)) return 'success';
if (['attention', 'warning', 'warn', 'stale', 'insufficient', 'degraded'].includes(normalized)) return 'warning';
if (['fail', 'failed', 'error', 'blocked', 'critical'].includes(normalized)) return 'danger';
if (['running', 'live', 'streaming'].includes(normalized)) return 'info';
return 'neutral';
}
export function ReportHero({
eyebrow,
title,
description,
verdict,
meta,
actions,
}: {
eyebrow: string;
title: string;
description: string;
verdict?: string;
meta?: ReactNode;
actions?: ReactNode;
}) {
const tone = reportTone(verdict ?? 'neutral');
return (
<section className="relative overflow-hidden rounded-[1.75rem] border border-slate-800 bg-slate-950 text-white shadow-[0_28px_80px_rgba(15,23,42,0.22)]">
<div className="pointer-events-none absolute inset-0 opacity-[0.16] [background-image:linear-gradient(rgba(148,163,184,0.18)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.18)_1px,transparent_1px)] [background-size:32px_32px]" />
<div className={`absolute inset-y-0 left-0 w-1.5 ${TONE[tone].line}`} />
<div className="relative grid gap-8 px-5 py-6 sm:px-8 sm:py-8 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
<div>
<div className="flex flex-wrap items-center gap-3">
<span className="text-[10px] font-black uppercase tracking-[0.24em] text-cyan-300">{eyebrow}</span>
<span className="h-px w-10 bg-slate-700" />
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Evidence-backed</span>
</div>
<h1 className="mt-4 max-w-4xl text-[2rem] font-semibold leading-[1.08] tracking-[-0.035em] text-white sm:text-[2.65rem]">{title}</h1>
<p className="mt-4 max-w-3xl text-sm leading-6 text-slate-300 sm:text-[15px]">{description}</p>
{meta && <div className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 font-mono text-[11px] text-slate-400">{meta}</div>}
</div>
<div className="flex flex-col items-start gap-3 xl:items-end">
{verdict && (
<span className={`inline-flex min-h-9 items-center rounded-full border px-4 py-2 text-[11px] font-black uppercase tracking-[0.18em] ${TONE[tone].badge}`}>
<span className={`mr-2 h-1.5 w-1.5 rounded-full ${TONE[tone].line}`} />
{verdict.replaceAll('_', ' ')}
</span>
)}
{actions && <div className="flex flex-wrap gap-2 xl:justify-end">{actions}</div>}
</div>
</div>
</section>
);
}
export function ReportPanel({
eyebrow,
title,
description,
right,
children,
className = '',
}: {
eyebrow?: string;
title: string;
description?: string;
right?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={`overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_14px_36px_rgba(15,23,42,0.055)] ${className}`}>
<header className="flex flex-col gap-4 border-b border-slate-100 px-5 py-5 sm:flex-row sm:items-start sm:justify-between sm:px-6">
<div>
{eyebrow && <p className="text-[10px] font-black uppercase tracking-[0.18em] text-cyan-700">{eyebrow}</p>}
<h2 className={`${eyebrow ? 'mt-1.5' : ''} text-base font-semibold tracking-[-0.01em] text-slate-950`}>{title}</h2>
{description && <p className="mt-1.5 max-w-3xl text-xs leading-5 text-slate-500">{description}</p>}
</div>
{right}
</header>
<div className="p-5 sm:p-6">{children}</div>
</section>
);
}
export function SignalMetric({
label,
value,
detail,
tone = 'neutral',
primary = false,
}: {
label: string;
value: ReactNode;
detail?: ReactNode;
tone?: Tone;
primary?: boolean;
}) {
return (
<div className={`relative min-h-[8.25rem] overflow-hidden rounded-2xl border p-4 sm:p-5 ${TONE[tone].wash}`}>
<span className={`absolute inset-y-0 left-0 w-1 ${TONE[tone].line}`} />
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-slate-500">{label}</p>
<div className={`mt-3 font-semibold tracking-[-0.035em] ${primary ? 'text-3xl sm:text-[2.1rem]' : 'text-2xl'} ${TONE[tone].value}`}>{value}</div>
{detail && <div className="mt-2 text-xs leading-5 text-slate-500">{detail}</div>}
</div>
);
}
export function CoverageBar({
label,
value,
detail,
}: {
label: string;
value: number;
detail: string;
}) {
const bounded = Math.min(Math.max(value, 0), 100);
const tone = bounded >= 95 ? 'bg-emerald-500' : bounded >= 70 ? 'bg-cyan-500' : bounded > 0 ? 'bg-amber-500' : 'bg-slate-300';
return (
<div>
<div className="flex items-baseline justify-between gap-4">
<span className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-500">{label}</span>
<span className="font-mono text-sm font-semibold text-slate-900">{bounded}%</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100">
<div className={`h-full rounded-full ${tone}`} style={{ width: `${bounded}%` }} />
</div>
<p className="mt-2 text-xs text-slate-500">{detail}</p>
</div>
);
}
export function DistributionBars({
label,
rows,
}: {
label: string;
rows: Array<{
label: string;
value: number;
detail?: ReactNode;
tone?: Tone;
}>;
}) {
const maximum = Math.max(...rows.map((row) => row.value), 1);
return (
<div role="img" aria-label={label}>
<ol className="space-y-4">
{rows.map((row) => {
const bounded = Math.max(row.value, 0);
const width = bounded === 0 ? 0 : Math.max((bounded / maximum) * 100, 3);
const tone = row.tone ?? 'info';
return (
<li key={row.label}>
<div className="flex items-end justify-between gap-4">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-slate-800">{row.label}</p>
{row.detail && <div className="mt-1 text-[11px] text-slate-500">{row.detail}</div>}
</div>
<span className="shrink-0 font-mono text-xs font-semibold text-slate-900">{integerLabel(bounded)}</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-100" aria-hidden="true">
<div className={`h-full rounded-full ${TONE[tone].line}`} style={{ width: `${width}%` }} />
</div>
</li>
);
})}
</ol>
{rows.length === 0 && <p className="text-sm text-slate-500">No measured distribution is available.</p>}
</div>
);
}
function integerLabel(value: number): string {
return new Intl.NumberFormat('en-US').format(value);
}
export function DisclosurePanel({
summary,
description,
badge,
children,
open = false,
}: {
summary: string;
description: string;
badge?: ReactNode;
children: ReactNode;
open?: boolean;
}) {
return (
<details open={open} className="group overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_10px_28px_rgba(15,23,42,0.04)]">
<summary className="flex min-h-16 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 outline-none transition hover:bg-slate-50 focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-cyan-100 [&::-webkit-details-marker]:hidden sm:px-6">
<div>
<p className="text-sm font-semibold text-slate-900">{summary}</p>
<p className="mt-1 text-xs leading-5 text-slate-500">{description}</p>
</div>
<div className="flex shrink-0 items-center gap-3">
{badge}
<span className="select-none text-lg text-slate-400 transition-transform group-open:rotate-45" aria-hidden="true">+</span>
</div>
</summary>
<div className="border-t border-slate-100 p-5 sm:p-6">{children}</div>
</details>
);
}
export const reportActionPrimary = 'inline-flex min-h-11 items-center justify-center rounded-xl bg-white px-4 py-2.5 text-xs font-bold text-slate-950 shadow-sm transition hover:-translate-y-px hover:bg-cyan-50 focus:outline-none focus:ring-2 focus:ring-cyan-300';
export const reportActionSecondary = 'inline-flex min-h-11 items-center justify-center rounded-xl border border-slate-700 bg-slate-900/70 px-4 py-2.5 text-xs font-bold text-slate-200 transition hover:-translate-y-px hover:border-slate-500 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-cyan-400';