144 lines
3.7 KiB
TypeScript
144 lines
3.7 KiB
TypeScript
import { TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, type LucideIcon } from 'lucide-react';
|
|
import { cn, fmt } from '@/lib/utils';
|
|
import type { Dollars } from '@/lib/money';
|
|
import { MetricCard } from '@/components/ui/app-primitives';
|
|
|
|
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> = {
|
|
starting: {
|
|
label: 'Starting cash',
|
|
icon: TrendingUp,
|
|
valueClass: 'text-foreground',
|
|
tone: 'neutral',
|
|
activateWhen: () => true,
|
|
},
|
|
paid: {
|
|
label: 'Paid',
|
|
icon: CheckCircle2,
|
|
valueClass: 'text-foreground',
|
|
tone: 'good',
|
|
activateWhen: (v) => v > 0,
|
|
},
|
|
remaining: {
|
|
label: 'Remaining',
|
|
icon: Clock,
|
|
valueClass: 'text-foreground',
|
|
tone: 'warn',
|
|
activateWhen: (v) => v > 0,
|
|
},
|
|
overdue: {
|
|
label: 'Overdue',
|
|
icon: AlertCircle,
|
|
valueClass: 'text-foreground',
|
|
tone: 'danger',
|
|
activateWhen: (v) => v > 0,
|
|
},
|
|
};
|
|
|
|
interface TrendInfo {
|
|
direction: string;
|
|
percent_change: number;
|
|
}
|
|
|
|
export function TrendIndicator({ trend }: { trend?: TrendInfo | null }) {
|
|
if (!trend) return null;
|
|
|
|
const { direction, percent_change } = trend;
|
|
|
|
let icon: string, color: string, text: string;
|
|
switch (direction) {
|
|
case 'up':
|
|
icon = '↑';
|
|
color = 'text-emerald-600 dark:text-emerald-300';
|
|
text = `${icon} ${percent_change}%`;
|
|
break;
|
|
case 'down':
|
|
icon = '↓';
|
|
color = 'text-rose-600 dark:text-rose-300';
|
|
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}`}>
|
|
{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) {
|
|
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 ? (
|
|
<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"
|
|
title="Edit monthly starting amounts"
|
|
aria-label="Edit monthly starting amounts"
|
|
>
|
|
<Settings2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
) : undefined}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function TrendCard({ trend }: { trend?: TrendInfo | null }) {
|
|
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>
|
|
)}
|
|
/>
|
|
);
|
|
}
|