244 lines
6.9 KiB
TypeScript
244 lines
6.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';
|
||
|
||
export const INSIGHT_STATUS_META: Record<
|
||
InsightStatus,
|
||
{ cls: string; glyph: string; label: string; bg: string; text: string; containerCls: string }
|
||
> = {
|
||
ok: {
|
||
cls: 'bg-green-50 text-green-800 border-green-200',
|
||
glyph: '✓',
|
||
label: 'Normal',
|
||
bg: 'bg-green-50',
|
||
text: 'text-green-800',
|
||
containerCls: 'bg-green-50/40 border-green-200',
|
||
},
|
||
warning: {
|
||
cls: 'bg-amber-50 text-amber-800 border-amber-200',
|
||
glyph: '!',
|
||
label: 'Perhatian',
|
||
bg: 'bg-amber-50',
|
||
text: 'text-amber-800',
|
||
containerCls: 'bg-amber-50/40 border-amber-200',
|
||
},
|
||
critical: {
|
||
cls: 'bg-red-50 text-red-800 border-red-200',
|
||
glyph: '⚠',
|
||
label: 'Kritis',
|
||
bg: 'bg-red-50',
|
||
text: 'text-red-800',
|
||
containerCls: 'bg-red-50/40 border-red-200',
|
||
},
|
||
unknown: {
|
||
cls: 'bg-gray-50 text-gray-600 border-gray-200',
|
||
glyph: '–',
|
||
label: 'Belum Dinilai',
|
||
bg: 'bg-gray-50',
|
||
text: 'text-gray-600',
|
||
containerCls: 'bg-gray-50/60 border-gray-200',
|
||
},
|
||
};
|
||
|
||
export const normalizeInsightStatus = (status: unknown): InsightStatus => {
|
||
if (!status || typeof status !== 'string') return 'unknown';
|
||
const lower = status.toLowerCase();
|
||
if (lower === 'ok' || lower === 'healthy' || lower === 'normal' || lower === 'aman') return 'ok';
|
||
if (lower === 'warning' || lower === 'perhatian' || lower === 'waspada') return 'warning';
|
||
if (lower === 'critical' || lower === 'kritis' || lower === 'bahaya') return 'critical';
|
||
return 'unknown';
|
||
};
|
||
|
||
export interface InsightStatusBadgeProps {
|
||
status: InsightStatus | string;
|
||
customLabel?: string;
|
||
className?: string;
|
||
size?: 'xs' | 'sm' | 'md';
|
||
}
|
||
|
||
export const InsightStatusBadge: React.FC<InsightStatusBadgeProps> = ({
|
||
status,
|
||
customLabel,
|
||
className = '',
|
||
size = 'sm',
|
||
}) => {
|
||
const norm = normalizeInsightStatus(status);
|
||
const meta = INSIGHT_STATUS_META[norm];
|
||
const sizeClasses =
|
||
size === 'xs'
|
||
? 'px-2 py-0.5 text-[10px]'
|
||
: size === 'md'
|
||
? 'px-3 py-1 text-xs'
|
||
: 'px-2.5 py-0.5 text-[11px]';
|
||
|
||
return (
|
||
<span
|
||
className={`inline-flex items-center gap-1.5 rounded-full border font-semibold tracking-wide ${meta.cls} ${sizeClasses} ${className}`}
|
||
>
|
||
<span aria-hidden="true" className="font-bold">
|
||
{meta.glyph}
|
||
</span>
|
||
<span>{customLabel || meta.label}</span>
|
||
</span>
|
||
);
|
||
};
|
||
|
||
interface DeltaChipProps {
|
||
status: InsightStatus;
|
||
label: string;
|
||
}
|
||
|
||
export const DeltaChip: React.FC<DeltaChipProps> = ({ status, label }) => {
|
||
const chip = INSIGHT_STATUS_META[status] ?? INSIGHT_STATUS_META.unknown;
|
||
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>
|
||
);
|