Files
pn-new-crm/src/components/ui/kpi-tile.tsx
Matt Ciaccio 4911083d0f fix(visual): KPITile data-testid + restore residential interest casing
Post-PR10c follow-ups discovered during smoke triage:
- KPITile gets data-testid="kpi-tile" so the dashboard smoke spec's
  '[data-testid*="kpi"]' selector matches (test 10-dashboard:27 expected
  >=4 kpi cards; the old Card-based render was matched by the
  '[class*="card"]' branch and didn't need a testid).
- Residential interest detail eyebrow text reverted from "Residential
  Interest" to "Residential interest" (lowercase i). The visual is
  identical because the wrapper has the `uppercase` class; the smoke
  spec at 26-residential:140 looks for the literal lowercase string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:56:32 +02:00

72 lines
2.2 KiB
TypeScript

import * as React from 'react';
import { cn } from '@/lib/utils';
interface KPITileProps extends React.HTMLAttributes<HTMLDivElement> {
title: string;
value: React.ReactNode;
/** Signed delta vs. prior period; positive = green, negative = red, undefined = no chip. */
delta?: number;
/** Pre-rendered sparkline (recharts) — caller decides shape. */
sparkline?: React.ReactNode;
/** Optional accent stripe colour token; defaults to brand. */
accent?: 'brand' | 'success' | 'warning' | 'mint' | 'teal' | 'purple';
}
const ACCENT_STRIPES: Record<NonNullable<KPITileProps['accent']>, string> = {
brand: 'bg-gradient-brand',
success: 'bg-success',
warning: 'bg-warning',
mint: 'bg-mint',
teal: 'bg-teal',
purple: 'bg-purple',
};
export function KPITile({
title,
value,
delta,
sparkline,
accent = 'brand',
className,
...props
}: KPITileProps) {
const deltaClass =
typeof delta === 'number'
? delta > 0
? 'text-success'
: delta < 0
? 'text-error'
: 'text-muted-foreground'
: '';
const deltaPrefix = typeof delta === 'number' ? (delta > 0 ? '+' : '') : '';
return (
<div
data-testid="kpi-tile"
className={cn(
'group relative overflow-hidden rounded-xl border border-border bg-gradient-brand-soft p-5 shadow-sm transition-all duration-base ease-smooth hover:shadow-md',
className,
)}
{...props}
>
<div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden />
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{title}
</div>
<div className="mt-2 text-2xl font-semibold tabular-nums text-foreground">{value}</div>
{typeof delta === 'number' ? (
<div className={cn('mt-1 text-xs font-medium', deltaClass)}>
{deltaPrefix}
{delta}
</div>
) : null}
</div>
{sparkline ? <div className="h-12 w-24 shrink-0 opacity-80">{sparkline}</div> : null}
</div>
</div>
);
}