238 lines
8.0 KiB
TypeScript
238 lines
8.0 KiB
TypeScript
import { useState } from 'react';
|
|
import { TrendingUp, TrendingDown, ChevronDown, ChevronUp, EyeOff } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/api';
|
|
import { cn, fmt } from '@/lib/utils';
|
|
import { asDollars } from '@/lib/money';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
|
import type { DriftBill } from '@/types';
|
|
|
|
function DriftRow({ row, refresh }: { row: DriftBill; refresh: () => void }) {
|
|
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/8 hover:text-amber-500 dark:text-amber-400'
|
|
: 'border-teal-400/40 text-teal-600 hover:border-teal-400/70 hover:bg-teal-500/8 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>
|
|
);
|
|
}
|
|
|
|
interface DriftInsightPanelProps {
|
|
driftBills?: DriftBill[];
|
|
refresh: () => void;
|
|
// Controlled + persisted collapse state (falls back to local state when omitted).
|
|
collapsed?: boolean;
|
|
onCollapsedChange?: (collapsed: boolean) => void;
|
|
// Fully hide the panel (writes the tracker_show_* setting on the page).
|
|
onHide?: () => void;
|
|
}
|
|
|
|
export default function DriftInsightPanel({
|
|
driftBills,
|
|
refresh,
|
|
collapsed,
|
|
onCollapsedChange,
|
|
onHide,
|
|
}: DriftInsightPanelProps) {
|
|
const [localOpen, setLocalOpen] = useState(true);
|
|
const isControlled = collapsed !== undefined;
|
|
const isOpen = isControlled ? !collapsed : localOpen;
|
|
const setOpen = (open: boolean) => {
|
|
if (isControlled) onCollapsedChange?.(!open);
|
|
else setLocalOpen(open);
|
|
};
|
|
|
|
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');
|
|
|
|
const ToggleIcon = isOpen ? ChevronDown : ChevronUp;
|
|
|
|
return (
|
|
<Collapsible open={isOpen} onOpenChange={setOpen}>
|
|
<div className="rounded-xl border border-amber-400/30 bg-amber-500/6 shadow-sm overflow-hidden dark:bg-amber-400/5">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-2 px-4 py-3">
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-expanded={isOpen}
|
|
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40"
|
|
>
|
|
{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(asDollars(Math.abs(totalNetDelta)))}/mo net
|
|
</span>
|
|
<ToggleIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
{onHide && (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={onHide}
|
|
className="h-8 shrink-0 gap-1.5 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
|
aria-label="Hide price-change insights"
|
|
>
|
|
<EyeOff className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">Hide</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|