139 lines
4.2 KiB
React
139 lines
4.2 KiB
React
|
|
import { TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2 } from 'lucide-react';
|
||
|
|
import { cn, fmt } from '@/lib/utils';
|
||
|
|
|
||
|
|
const CARD_DEFS = {
|
||
|
|
starting: {
|
||
|
|
label: 'Starting',
|
||
|
|
icon: TrendingUp,
|
||
|
|
bar: 'from-slate-400 to-slate-300',
|
||
|
|
glow: '',
|
||
|
|
valueClass: 'text-foreground',
|
||
|
|
activateWhen: () => true,
|
||
|
|
},
|
||
|
|
paid: {
|
||
|
|
label: 'Total Paid',
|
||
|
|
icon: CheckCircle2,
|
||
|
|
bar: 'from-emerald-500 to-emerald-300',
|
||
|
|
glow: 'shadow-[0_4px_20px_rgba(16,185,129,0.15)]',
|
||
|
|
borderActive: 'border-emerald-400/40',
|
||
|
|
valueClass: 'text-emerald-600 dark:text-emerald-200',
|
||
|
|
activateWhen: (v) => v > 0,
|
||
|
|
},
|
||
|
|
remaining: {
|
||
|
|
label: 'Remaining',
|
||
|
|
icon: Clock,
|
||
|
|
bar: 'from-blue-400 to-indigo-300',
|
||
|
|
glow: '',
|
||
|
|
valueClass: 'text-foreground',
|
||
|
|
activateWhen: () => true,
|
||
|
|
},
|
||
|
|
overdue: {
|
||
|
|
label: 'Overdue',
|
||
|
|
icon: AlertCircle,
|
||
|
|
bar: 'from-rose-400 to-orange-300',
|
||
|
|
glow: 'shadow-[0_4px_20px_rgba(251,113,133,0.10)]',
|
||
|
|
borderActive: 'border-rose-400/35',
|
||
|
|
valueClass: 'text-red-500 dark:text-rose-200',
|
||
|
|
activateWhen: (v) => v > 0,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
export function TrendIndicator({ trend }) {
|
||
|
|
if (!trend) return null;
|
||
|
|
|
||
|
|
const { direction, percent_change } = trend;
|
||
|
|
|
||
|
|
let icon, color, text;
|
||
|
|
switch (direction) {
|
||
|
|
case 'up':
|
||
|
|
icon = '↑';
|
||
|
|
color = 'text-emerald-500';
|
||
|
|
text = `${icon} ${percent_change}%`;
|
||
|
|
break;
|
||
|
|
case 'down':
|
||
|
|
icon = '↓';
|
||
|
|
color = 'text-red-500';
|
||
|
|
text = `${icon} ${Math.abs(percent_change)}%`;
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
icon = '→';
|
||
|
|
color = 'text-muted-foreground';
|
||
|
|
text = `${icon} ${percent_change}%`;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex items-center gap-1.5">
|
||
|
|
<span className={`text-lg font-bold ${color}`}>
|
||
|
|
{text}
|
||
|
|
</span>
|
||
|
|
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||
|
|
vs 3-mo avg
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function SummaryCard({ type, value, onEdit, hint, label }) {
|
||
|
|
const def = CARD_DEFS[type];
|
||
|
|
const isActive = def.activateWhen(value || 0);
|
||
|
|
const Icon = def.icon;
|
||
|
|
const displayLabel = label || def.label;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn(
|
||
|
|
'flex-1 min-w-0 relative overflow-hidden rounded-xl border border-border/80',
|
||
|
|
'bg-card/95 px-5 py-4 shadow-sm shadow-black/15 transition-all duration-300',
|
||
|
|
isActive && def.glow,
|
||
|
|
isActive && def.borderActive,
|
||
|
|
)}>
|
||
|
|
<div className={cn(
|
||
|
|
'absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r',
|
||
|
|
def.bar,
|
||
|
|
!isActive && (type === 'paid' || type === 'overdue') && 'opacity-30',
|
||
|
|
)} />
|
||
|
|
<div className="flex items-center gap-2 mb-3">
|
||
|
|
<Icon className={cn('h-4 w-4', isActive ? def.valueClass : 'text-muted-foreground')} />
|
||
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||
|
|
{displayLabel}
|
||
|
|
</p>
|
||
|
|
{type === 'starting' && onEdit && (
|
||
|
|
<button
|
||
|
|
onClick={onEdit}
|
||
|
|
className="ml-auto h-4 w-4 text-muted-foreground hover:text-foreground transition-colors"
|
||
|
|
title="Edit monthly starting amounts"
|
||
|
|
aria-label="Edit monthly starting amounts"
|
||
|
|
>
|
||
|
|
<Settings2 className="h-4 w-4" />
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<p className={cn(
|
||
|
|
'text-[1.75rem] font-bold tracking-tight font-mono leading-none',
|
||
|
|
isActive ? def.valueClass : 'text-foreground',
|
||
|
|
)}>
|
||
|
|
{fmt(value)}
|
||
|
|
</p>
|
||
|
|
{hint && <p className="mt-2 text-[11px] text-muted-foreground">{hint}</p>}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function TrendCard({ trend }) {
|
||
|
|
if (!trend) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex-1 min-w-0 relative overflow-hidden rounded-xl border border-border/80 bg-card/95 px-5 py-4 shadow-sm shadow-black/15 transition-all duration-300">
|
||
|
|
<div className="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-purple-500 to-indigo-400" />
|
||
|
|
<div className="flex items-center gap-2 mb-3">
|
||
|
|
<TrendingUp className="h-4 w-4 text-foreground" />
|
||
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||
|
|
3-Month Trend
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-center h-10">
|
||
|
|
<TrendIndicator trend={trend} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|