BillTracker/client/components/tracker/SummaryCards.tsx

144 lines
3.7 KiB
TypeScript
Raw Normal View History

import { TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, type LucideIcon } from 'lucide-react';
2026-05-31 15:06:10 -05:00
import { cn, fmt } from '@/lib/utils';
import type { Dollars } from '@/lib/money';
import { MetricCard } from '@/components/ui/app-primitives';
2026-05-31 15:06:10 -05:00
type CardType = 'starting' | 'paid' | 'remaining' | 'overdue';
interface CardDef {
label: string;
icon: LucideIcon;
valueClass: string;
tone: 'neutral' | 'good' | 'warn' | 'danger' | 'info';
activateWhen: (v: number) => boolean;
}
const CARD_DEFS: Record<CardType, CardDef> = {
2026-05-31 15:06:10 -05:00
starting: {
label: 'Starting cash',
2026-05-31 15:06:10 -05:00
icon: TrendingUp,
valueClass: 'text-foreground',
tone: 'neutral',
2026-05-31 15:06:10 -05:00
activateWhen: () => true,
},
paid: {
label: 'Paid',
2026-05-31 15:06:10 -05:00
icon: CheckCircle2,
valueClass: 'text-foreground',
tone: 'good',
2026-05-31 15:06:10 -05:00
activateWhen: (v) => v > 0,
},
remaining: {
label: 'Remaining',
icon: Clock,
valueClass: 'text-foreground',
tone: 'warn',
activateWhen: (v) => v > 0,
2026-05-31 15:06:10 -05:00
},
overdue: {
label: 'Overdue',
icon: AlertCircle,
valueClass: 'text-foreground',
tone: 'danger',
2026-05-31 15:06:10 -05:00
activateWhen: (v) => v > 0,
},
};
interface TrendInfo {
direction: string;
percent_change: number;
}
export function TrendIndicator({ trend }: { trend?: TrendInfo | null }) {
2026-05-31 15:06:10 -05:00
if (!trend) return null;
const { direction, percent_change } = trend;
let icon: string, color: string, text: string;
2026-05-31 15:06:10 -05:00
switch (direction) {
case 'up':
icon = '↑';
color = 'text-emerald-600 dark:text-emerald-300';
2026-05-31 15:06:10 -05:00
text = `${icon} ${percent_change}%`;
break;
case 'down':
icon = '↓';
color = 'text-rose-600 dark:text-rose-300';
2026-05-31 15:06:10 -05:00
text = `${icon} ${Math.abs(percent_change)}%`;
break;
default:
icon = '→';
color = 'text-muted-foreground';
text = `${icon} ${percent_change}%`;
}
return (
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1">
<span className={`tracker-number text-lg font-bold ${color}`}>
2026-05-31 15:06:10 -05:00
{text}
</span>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
vs 3-mo avg
</span>
</div>
);
}
interface SummaryCardProps {
type: CardType;
value?: Dollars;
onEdit?: () => void;
hint?: string;
label?: string;
}
export function SummaryCard({ type, value, onEdit, hint, label }: SummaryCardProps) {
2026-05-31 15:06:10 -05:00
const def = CARD_DEFS[type];
const isActive = def.activateWhen(value || 0);
const Icon = def.icon;
const displayLabel = label || def.label;
return (
<MetricCard
className="min-w-0 flex-1"
tone={isActive ? def.tone : 'neutral'}
icon={Icon}
label={displayLabel}
value={(
<span className={cn('block tabular-nums', isActive ? def.valueClass : 'text-foreground')}>
{fmt(value)}
</span>
)}
hint={hint}
action={type === 'starting' && onEdit ? (
2026-05-31 15:06:10 -05:00
<button
onClick={onEdit}
className="ml-auto grid h-7 w-7 place-items-center rounded-lg border border-border/60 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
2026-05-31 15:06:10 -05:00
title="Edit monthly starting amounts"
aria-label="Edit monthly starting amounts"
>
<Settings2 className="h-3.5 w-3.5" />
2026-05-31 15:06:10 -05:00
</button>
) : undefined}
/>
2026-05-31 15:06:10 -05:00
);
}
export function TrendCard({ trend }: { trend?: TrendInfo | null }) {
2026-05-31 15:06:10 -05:00
if (!trend) return null;
return (
<MetricCard
className="min-w-0 flex-1"
tone="info"
icon={TrendingUp}
label="3-Month Trend"
value={(
<span className="flex min-h-8 items-center">
<TrendIndicator trend={trend} />
</span>
)}
/>
2026-05-31 15:06:10 -05:00
);
}