170 lines
4.9 KiB
TypeScript
170 lines
4.9 KiB
TypeScript
import React from 'react';
|
||
|
||
/**
|
||
* Headline figures for AI Insight cards.
|
||
* Matches NEW dashboard gray card patterns (white + border-gray-100).
|
||
*/
|
||
|
||
export type InsightStatus = 'ok' | 'warning' | 'critical' | 'unknown';
|
||
|
||
const STATUS_CHIP: Record<InsightStatus, { cls: string; glyph: string }> = {
|
||
ok: { cls: 'bg-green-50 text-green-800 border-green-200', glyph: '✓' },
|
||
warning: { cls: 'bg-amber-50 text-amber-800 border-amber-200', glyph: '!' },
|
||
critical: { cls: 'bg-red-50 text-red-800 border-red-200', glyph: '⚠' },
|
||
unknown: { cls: 'bg-gray-50 text-gray-500 border-gray-200', glyph: '–' },
|
||
};
|
||
|
||
interface DeltaChipProps {
|
||
status: InsightStatus;
|
||
label: string;
|
||
}
|
||
|
||
export const DeltaChip: React.FC<DeltaChipProps> = ({ status, label }) => {
|
||
const chip = STATUS_CHIP[status];
|
||
return (
|
||
<span
|
||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${chip.cls}`}
|
||
>
|
||
<span aria-hidden="true">{chip.glyph}</span>
|
||
{label}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
/** Axis-less sparkline — shape only; the tile number carries the value. */
|
||
export const Sparkline: React.FC<{ values: number[]; className?: string }> = ({
|
||
values,
|
||
className,
|
||
}) => {
|
||
const clean = values.filter((v) => typeof v === 'number' && Number.isFinite(v));
|
||
if (clean.length < 2) return null;
|
||
|
||
const width = 88;
|
||
const height = 28;
|
||
const min = Math.min(...clean);
|
||
const max = Math.max(...clean);
|
||
const span = max - min || 1;
|
||
const step = width / (clean.length - 1);
|
||
const points = clean
|
||
.map((value, index) => {
|
||
const x = index * step;
|
||
const y = height - 2 - ((value - min) / span) * (height - 4);
|
||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||
})
|
||
.join(' ');
|
||
|
||
return (
|
||
<svg
|
||
viewBox={`0 0 ${width} ${height}`}
|
||
className={className}
|
||
width={width}
|
||
height={height}
|
||
role="presentation"
|
||
aria-hidden="true"
|
||
preserveAspectRatio="none"
|
||
>
|
||
<polyline
|
||
points={points}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth={2}
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
);
|
||
};
|
||
|
||
interface HeroFigureProps {
|
||
label: string;
|
||
value: string;
|
||
unit?: string;
|
||
context?: string | null;
|
||
status?: InsightStatus;
|
||
deltaLabel?: string | null;
|
||
sparkline?: number[];
|
||
}
|
||
|
||
export const HeroFigure: React.FC<HeroFigureProps> = ({
|
||
label,
|
||
value,
|
||
unit,
|
||
context,
|
||
status = 'unknown',
|
||
deltaLabel,
|
||
sparkline,
|
||
}) => (
|
||
<div className="flex items-end justify-between gap-4 rounded-xl border border-gray-100 bg-white px-5 py-4 shadow-sm">
|
||
<div className="min-w-0">
|
||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500">{label}</div>
|
||
<div className="mt-1 flex items-baseline gap-1.5 pb-0.5">
|
||
<span className="text-5xl font-bold leading-[1.15] tracking-tight text-gray-900">
|
||
{value}
|
||
</span>
|
||
{unit ? <span className="text-sm font-medium text-gray-500">{unit}</span> : null}
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||
{deltaLabel ? <DeltaChip status={status} label={deltaLabel} /> : null}
|
||
{context ? <span className="text-xs text-gray-500">{context}</span> : null}
|
||
</div>
|
||
</div>
|
||
{sparkline && sparkline.length > 1 ? (
|
||
<Sparkline values={sparkline} className="shrink-0 text-red-400" />
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
interface StatTileProps {
|
||
label: string;
|
||
value: string;
|
||
unit?: string;
|
||
hint?: string | null;
|
||
status?: InsightStatus;
|
||
deltaLabel?: string | null;
|
||
sparkline?: number[];
|
||
}
|
||
|
||
export const StatTile: React.FC<StatTileProps> = ({
|
||
label,
|
||
value,
|
||
unit,
|
||
hint,
|
||
status = 'unknown',
|
||
deltaLabel,
|
||
sparkline,
|
||
}) => (
|
||
<div className="rounded-xl border border-gray-100 bg-white px-4 py-3 shadow-sm">
|
||
<div className="text-xs font-medium text-gray-500">
|
||
{label}
|
||
{hint ? <span className="ml-1 text-[11px] text-gray-400">{hint}</span> : null}
|
||
</div>
|
||
<div className="mt-1 flex items-baseline gap-1">
|
||
<span className="text-xl font-bold leading-[1.3] text-gray-900">{value}</span>
|
||
{unit ? <span className="text-xs font-medium text-gray-500">{unit}</span> : null}
|
||
</div>
|
||
{deltaLabel ? (
|
||
<div className="mt-1.5">
|
||
<DeltaChip status={status} label={deltaLabel} />
|
||
</div>
|
||
) : null}
|
||
{sparkline && sparkline.length > 1 ? (
|
||
<Sparkline values={sparkline} className="mt-2 text-gray-300" />
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
/** Secondary figures: subordinate to hero / tiles. */
|
||
export const MutedStatRow: React.FC<{ label: string; hint?: string | null; value: string }> = ({
|
||
label,
|
||
hint,
|
||
value,
|
||
}) => (
|
||
<div className="flex items-center justify-between border-b border-gray-50 py-1.5">
|
||
<span className="text-sm text-gray-600">
|
||
{label}
|
||
{hint ? <span className="ml-1 text-xs text-gray-400">{hint}</span> : null}
|
||
</span>
|
||
<span className="text-sm font-semibold text-gray-900">{value}</span>
|
||
</div>
|
||
);
|