BillTracker/client/components/tracker/DriftInsightPanel.jsx

190 lines
6.6 KiB
JavaScript

import React, { useState } from 'react';
import { TrendingUp, TrendingDown, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { cn, fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
function DriftRow({ row, refresh }) {
const [loading, setLoading] = useState(false);
async function handleUpdate() {
setLoading(true);
const oldAmount = row.expected_amount;
try {
await api.updateBill(row.id, { expected_amount: row.recent_amount });
toast.success(`"${row.name}" updated to ${fmt(row.recent_amount)}`, {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.updateBill(row.id, { expected_amount: oldAmount });
toast.success('Reverted');
refresh();
} catch {
toast.error('Failed to revert');
}
},
},
});
refresh();
} catch {
toast.error('Failed to update bill');
} finally {
setLoading(false);
}
}
async function handleDismiss() {
setLoading(true);
try {
await api.snoozeBillDrift(row.id);
toast.success('Hidden for 30 days');
refresh();
} catch {
toast.error('Failed to dismiss');
} finally {
setLoading(false);
}
}
const isUp = row.direction === 'up';
const sign = isUp ? '+' : '';
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 sm:flex-nowrap">
{/* Bill info */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-medium text-foreground">{row.name}</span>
{row.category_name && (
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[10px]">
{row.category_name}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5">
{isUp
? <TrendingUp className="h-3 w-3 text-amber-500 dark:text-amber-400" />
: <TrendingDown className="h-3 w-3 text-teal-500 dark:text-teal-400" />
}
<span className={cn(
'text-xs font-medium',
isUp ? 'text-amber-600 dark:text-amber-400' : 'text-teal-600 dark:text-teal-400'
)}>
{sign}{row.drift_pct}% over {row.months_sampled} months
</span>
</div>
</div>
{/* Amount change */}
<div className="flex shrink-0 items-center gap-1.5">
<span className="font-mono text-xs text-muted-foreground line-through">
{fmt(row.expected_amount)}
</span>
<span className="text-muted-foreground"></span>
<span className={cn(
'font-mono text-sm font-semibold',
isUp ? 'text-amber-500 dark:text-amber-400' : 'text-teal-500 dark:text-teal-400'
)}>
{fmt(row.recent_amount)}
</span>
<span className={cn(
'rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none',
isUp
? 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
: 'bg-teal-500/15 text-teal-700 dark:text-teal-300'
)}>
{sign}{row.drift_pct}%
</span>
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
<Button
size="sm"
variant="outline"
className={cn(
'h-7 gap-1.5 px-2.5 text-xs',
isUp
? 'border-amber-400/40 text-amber-600 hover:border-amber-400/70 hover:bg-amber-500/[0.08] hover:text-amber-500 dark:text-amber-400'
: 'border-teal-400/40 text-teal-600 hover:border-teal-400/70 hover:bg-teal-500/[0.08] hover:text-teal-500 dark:text-teal-400'
)}
disabled={loading}
onClick={handleUpdate}
>
Update to {fmt(row.recent_amount)}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2.5 text-xs text-muted-foreground hover:text-foreground"
disabled={loading}
onClick={handleDismiss}
>
Dismiss
</Button>
</div>
</div>
);
}
export default function DriftInsightPanel({ driftBills, refresh }) {
const [isOpen, setIsOpen] = useState(true);
if (!driftBills?.length) return null;
const totalNetDelta = driftBills.reduce((sum, b) => sum + (b.recent_amount - b.expected_amount), 0);
const sign = totalNetDelta >= 0 ? '+' : '';
const netColor = totalNetDelta >= 0 ? 'text-amber-500 dark:text-amber-400' : 'text-teal-500 dark:text-teal-400';
const hasIncrease = driftBills.some(b => b.direction === 'up');
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="rounded-xl border border-amber-400/30 bg-amber-500/[0.06] shadow-sm overflow-hidden dark:bg-amber-400/[0.05]">
{/* Header */}
<CollapsibleTrigger asChild>
<button className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-amber-500/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40 focus-visible:ring-inset">
<div className="flex items-center gap-2.5">
{hasIncrease
? <TrendingUp className="h-4 w-4 shrink-0 text-amber-400" />
: <TrendingDown className="h-4 w-4 shrink-0 text-teal-400" />
}
<span className="text-sm font-semibold text-foreground">
{driftBills.length === 1
? '1 bill changed price'
: `${driftBills.length} bills changed price`}
</span>
<span className={cn('font-mono text-sm', netColor)}>
{sign}{fmt(Math.abs(totalNetDelta))}/mo net
</span>
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform duration-200',
isOpen && 'rotate-180'
)}
/>
</button>
</CollapsibleTrigger>
{/* Bill rows */}
<CollapsibleContent>
<div className="divide-y divide-border/40 px-4 pb-2">
{driftBills.map(row => (
<DriftRow
key={row.id}
row={row}
refresh={refresh}
/>
))}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}