snowball visuals
This commit is contained in:
parent
7aff0d0283
commit
eea5641126
|
|
@ -144,6 +144,7 @@ export const api = {
|
||||||
updateBill: (id, data) => put(`/bills/${id}`, data),
|
updateBill: (id, data) => put(`/bills/${id}`, data),
|
||||||
updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
|
updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
|
||||||
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
|
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
|
||||||
|
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
|
||||||
deleteBill: (id) => del(`/bills/${id}`),
|
deleteBill: (id) => del(`/bills/${id}`),
|
||||||
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
||||||
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
|
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, X } from 'lucide-react';
|
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -470,7 +470,12 @@ export default function SnowballPage() {
|
||||||
const isAttack = index === 0;
|
const isAttack = index === 0;
|
||||||
const isEditingBal = editingBalance.billId === bill.id;
|
const isEditingBal = editingBalance.billId === bill.id;
|
||||||
const isDragging = draggingIdx !== null;
|
const isDragging = draggingIdx !== null;
|
||||||
const isTarget = draggingIdx === index; // where it will land
|
const isTarget = draggingIdx === index;
|
||||||
|
|
||||||
|
// Pull this debt's payoff info from the snowball projection (attack card only)
|
||||||
|
const attackProjection = isAttack
|
||||||
|
? projection?.snowball?.debts?.[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -479,65 +484,46 @@ export default function SnowballPage() {
|
||||||
data-card-index={index}
|
data-card-index={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
|
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
|
||||||
isAttack ? 'border-emerald-500/40' : 'border-border/40',
|
isAttack ? 'border-emerald-500/30 bg-emerald-950/5' : 'border-border/40',
|
||||||
isTarget && isDragging && 'ring-2 ring-primary/50 scale-[0.99]',
|
isTarget && isDragging && 'ring-2 ring-primary/50 scale-[0.99]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-stretch">
|
<div className="flex items-stretch">
|
||||||
|
|
||||||
{/* Grip handle — pointer-capture trigger */}
|
{/* Grip */}
|
||||||
<div
|
<div
|
||||||
data-grip
|
data-grip
|
||||||
onPointerDown={e => onPointerDown(e, index)}
|
onPointerDown={e => onPointerDown(e, index)}
|
||||||
className="flex items-center px-3 text-muted-foreground/30 hover:text-muted-foreground/70 cursor-grab active:cursor-grabbing transition-colors touch-none"
|
className="flex items-center px-3 text-muted-foreground/20 hover:text-muted-foreground/60 cursor-grab active:cursor-grabbing transition-colors touch-none shrink-0"
|
||||||
aria-label="Drag to reorder"
|
aria-label="Drag to reorder"
|
||||||
>
|
>
|
||||||
<GripVertical className="h-5 w-5" />
|
<GripVertical className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Main content */}
|
||||||
<div className="flex-1 py-3.5 pr-4 min-w-0">
|
<div className="flex-1 py-3 min-w-0">
|
||||||
{/* Top row */}
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
{/* Name row */}
|
||||||
<span className="text-xs font-bold text-muted-foreground tabular-nums w-5 shrink-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
#{index + 1}
|
{isAttack ? (
|
||||||
</span>
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-400 shrink-0">
|
||||||
{isAttack && (
|
<Zap className="h-2.5 w-2.5" /> Now
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400 shrink-0">
|
</span>
|
||||||
<Zap className="h-2.5 w-2.5" /> Attack
|
) : (
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground/50 tabular-nums shrink-0 w-5">
|
||||||
|
#{index + 1}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="font-semibold truncate">{bill.name}</span>
|
<span className="font-semibold text-sm truncate">{bill.name}</span>
|
||||||
{bill.category_name && (
|
{bill.category_name && (
|
||||||
<span className="text-[10px] text-muted-foreground border border-border/50 rounded px-1.5 py-0.5 shrink-0">
|
<span className="text-[10px] text-muted-foreground/60 shrink-0 hidden sm:inline">
|
||||||
{bill.category_name}
|
{bill.category_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{bill.snowball_include === 1 && !bill.category_name && (
|
|
||||||
<span className="text-[10px] text-violet-400 border border-violet-500/30 rounded px-1.5 py-0.5 shrink-0">
|
|
||||||
manual
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditBill(bill)}
|
|
||||||
className="ml-auto text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeFromSnowball(bill)}
|
|
||||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-amber-400 transition-colors shrink-0"
|
|
||||||
title="Remove from Snowball"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats row */}
|
{/* Stats row */}
|
||||||
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1.5 text-sm items-center">
|
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-sm items-center">
|
||||||
|
|
||||||
{/* Balance — inline editable */}
|
{/* Balance — inline editable */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|
@ -559,38 +545,46 @@ export default function SnowballPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => startEditBalance(bill)}
|
onClick={() => startEditBalance(bill)}
|
||||||
|
title="Click to update balance"
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-semibold tabular-nums rounded px-1 -mx-1 hover:bg-muted/60 transition-colors',
|
'font-semibold tabular-nums rounded px-1 -mx-1 hover:bg-muted/60 transition-colors',
|
||||||
isAttack && bill.current_balance != null ? 'text-emerald-400' : '',
|
isAttack && bill.current_balance != null && 'text-emerald-400',
|
||||||
bill.current_balance == null && 'text-muted-foreground/60 italic text-xs',
|
bill.current_balance == null && 'text-muted-foreground/50 italic text-xs',
|
||||||
)}
|
)}
|
||||||
title="Click to update balance"
|
|
||||||
>
|
>
|
||||||
{bill.current_balance != null ? fmt(bill.current_balance) : 'enter balance'}
|
{bill.current_balance != null ? fmt(bill.current_balance) : 'add balance'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{bill.minimum_payment != null && (
|
||||||
<span className="text-xs text-muted-foreground">Min/mo </span>
|
<div>
|
||||||
<span className="font-medium tabular-nums">
|
<span className="text-xs text-muted-foreground">Min </span>
|
||||||
{bill.minimum_payment != null ? fmt(bill.minimum_payment) : '—'}
|
<span className="font-medium tabular-nums">{fmt(bill.minimum_payment)}</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{isAttack && extraAmt > 0 && (
|
{isAttack && extraAmt > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-muted-foreground">Attack </span>
|
<span className="text-xs text-muted-foreground">Throwing </span>
|
||||||
<span className="font-medium tabular-nums text-emerald-400">
|
<span className="font-semibold tabular-nums text-emerald-400">
|
||||||
{fmt((bill.minimum_payment || 0) + extraAmt)}
|
{fmt((bill.minimum_payment || 0) + extraAmt)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground"> /mo</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{bill.interest_rate != null && (
|
{bill.interest_rate != null && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-muted-foreground">APR </span>
|
<span className="text-xs text-muted-foreground">APR </span>
|
||||||
<span className="font-medium tabular-nums">{bill.interest_rate}%</span>
|
<span className={cn(
|
||||||
|
'font-medium tabular-nums',
|
||||||
|
bill.interest_rate >= 25 ? 'text-rose-400' :
|
||||||
|
bill.interest_rate >= 15 ? 'text-amber-400' :
|
||||||
|
'text-muted-foreground',
|
||||||
|
)}>
|
||||||
|
{bill.interest_rate}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -599,7 +593,38 @@ export default function SnowballPage() {
|
||||||
<span className="font-medium">{ordinal(bill.due_day)}</span>
|
<span className="font-medium">{ordinal(bill.due_day)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Attack card payoff line — from projection */}
|
||||||
|
{isAttack && attackProjection?.payoff_display && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-emerald-400/80">
|
||||||
|
<span className="font-medium">↳ Clears {attackProjection.payoff_display}</span>
|
||||||
|
{attackProjection.total_interest > 0 && (
|
||||||
|
<span className="text-muted-foreground">· {fmtCompact(attackProjection.total_interest)} interest</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action icons — fixed right column */}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1 px-3 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditBill(bill)}
|
||||||
|
title="Edit bill"
|
||||||
|
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||||
|
>
|
||||||
|
<PenLine className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFromSnowball(bill)}
|
||||||
|
title="Hide from Snowball"
|
||||||
|
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-amber-400 hover:bg-amber-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -499,6 +499,25 @@ router.delete('/:id/history-ranges/:rangeId', (req, res) => {
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── PATCH /api/bills/:id/snowball — update only snowball_include / snowball_exempt ──
|
||||||
|
router.patch('/:id/snowball', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const billId = parseInt(req.params.id, 10);
|
||||||
|
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) {
|
||||||
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
}
|
||||||
|
const include = req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined;
|
||||||
|
const exempt = req.body.snowball_exempt !== undefined ? (req.body.snowball_exempt ? 1 : 0) : undefined;
|
||||||
|
const parts = [];
|
||||||
|
const vals = [];
|
||||||
|
if (include !== undefined) { parts.push('snowball_include = ?'); vals.push(include); }
|
||||||
|
if (exempt !== undefined) { parts.push('snowball_exempt = ?'); vals.push(exempt); }
|
||||||
|
if (parts.length === 0) return res.status(400).json(standardizeError('Nothing to update', 'VALIDATION_ERROR'));
|
||||||
|
parts.push("updated_at = datetime('now')");
|
||||||
|
db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run(...vals, billId, req.user.id);
|
||||||
|
res.json({ id: billId, snowball_include: include, snowball_exempt: exempt });
|
||||||
|
});
|
||||||
|
|
||||||
// ── PATCH /api/bills/:id/balance — lightweight balance-only update ────────────
|
// ── PATCH /api/bills/:id/balance — lightweight balance-only update ────────────
|
||||||
router.patch('/:id/balance', (req, res) => {
|
router.patch('/:id/balance', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue