2026-05-28 02:09:49 -05:00
|
|
|
|
import React, { useState, useEffect, useCallback, useRef, useMemo, useTransition } from 'react';
|
2026-05-16 15:38:28 -05:00
|
|
|
|
import { useSearchParams } from 'react-router-dom';
|
2026-05-28 02:34:24 -05:00
|
|
|
|
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X, RefreshCw } from 'lucide-react';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
import { api } from '@/api.js';
|
2026-05-10 03:10:43 -05:00
|
|
|
|
import { useTracker } from '@/hooks/useQueries';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
import BillModal from '@/components/BillModal';
|
2026-05-16 15:38:28 -05:00
|
|
|
|
import { makeBillDraft } from '@/lib/billDrafts';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
2026-05-10 01:35:41 -05:00
|
|
|
|
import { Skeleton } from '@/components/ui/Skeleton';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
import {
|
|
|
|
|
|
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
|
|
|
|
|
|
} from '@/components/ui/table';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
|
|
|
|
} from '@/components/ui/dialog';
|
2026-05-16 10:34:32 -05:00
|
|
|
|
import {
|
|
|
|
|
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
|
|
|
|
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
|
|
|
|
|
} from '@/components/ui/alert-dialog';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
import {
|
|
|
|
|
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
|
|
|
|
|
} from '@/components/ui/select';
|
|
|
|
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
|
|
|
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
|
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
|
|
|
|
|
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
|
|
|
|
|
|
import PaymentModal from '@/components/tracker/PaymentModal';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const MONTHS = [
|
|
|
|
|
|
'January','February','March','April','May','June',
|
|
|
|
|
|
'July','August','September','October','November','December',
|
|
|
|
|
|
];
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const FILTER_ALL = 'all';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
// Sentinel for the "no method" select option — empty string crashes Radix Select
|
|
|
|
|
|
const METHOD_NONE = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
function paymentDateForTrackerMonth(year, month, dueDay) {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
if (year === now.getFullYear() && month === now.getMonth() + 1) {
|
|
|
|
|
|
return todayStr();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const daysInMonth = new Date(year, month, 0).getDate();
|
|
|
|
|
|
const day = Number.isInteger(Number(dueDay))
|
|
|
|
|
|
? Math.min(Math.max(Number(dueDay), 1), daysInMonth)
|
|
|
|
|
|
: 1;
|
|
|
|
|
|
|
|
|
|
|
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ROW_STATUS_CLS = {
|
2026-05-28 19:58:01 -05:00
|
|
|
|
paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]',
|
|
|
|
|
|
autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
|
2026-05-03 19:51:57 -05:00
|
|
|
|
upcoming: '',
|
2026-05-28 19:58:01 -05:00
|
|
|
|
due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]',
|
2026-05-28 23:42:46 -05:00
|
|
|
|
late: 'border-l-4 border-l-orange-400 bg-orange-500/[0.16] ring-1 ring-inset ring-orange-400/25 dark:bg-orange-400/[0.11] dark:ring-orange-300/25',
|
|
|
|
|
|
missed: 'border-l-4 border-l-rose-400 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.13] dark:ring-rose-300/30',
|
2026-05-03 19:51:57 -05:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const STATUS_META = {
|
2026-05-28 19:40:55 -05:00
|
|
|
|
paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/30' },
|
2026-05-03 19:51:57 -05:00
|
|
|
|
upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' },
|
2026-05-28 19:40:55 -05:00
|
|
|
|
due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' },
|
2026-05-28 23:42:46 -05:00
|
|
|
|
late: { label: 'Late', cls: 'bg-orange-500/30 text-orange-800 border border-orange-500/60 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' },
|
|
|
|
|
|
missed: { label: 'Missed', cls: 'bg-rose-500/30 text-rose-800 border border-rose-500/70 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' },
|
2026-05-28 19:40:55 -05:00
|
|
|
|
autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30 dark:bg-sky-300/10 dark:text-sky-200 dark:border-sky-300/28' },
|
2026-05-03 19:51:57 -05:00
|
|
|
|
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
function amountSearchText(...values) {
|
|
|
|
|
|
return values
|
|
|
|
|
|
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
|
|
|
|
|
|
.flatMap(value => {
|
|
|
|
|
|
const num = Number(value);
|
|
|
|
|
|
return [String(num), num.toFixed(2), `$${num.toFixed(2)}`];
|
|
|
|
|
|
})
|
|
|
|
|
|
.join(' ');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rowThreshold(row) {
|
|
|
|
|
|
return row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rowEffectiveStatus(row) {
|
|
|
|
|
|
if (row.is_skipped) return 'skipped';
|
|
|
|
|
|
const threshold = rowThreshold(row);
|
|
|
|
|
|
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
|
|
|
|
|
return (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rowIsPaid(row) {
|
|
|
|
|
|
const status = rowEffectiveStatus(row);
|
|
|
|
|
|
if (row.autopay_suggestion && status === 'autodraft') return false;
|
|
|
|
|
|
return status === 'paid' || status === 'autodraft';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rowIsDebt(row) {
|
|
|
|
|
|
const category = String(row.category_name || '').toLowerCase();
|
|
|
|
|
|
return Number(row.current_balance) > 0
|
|
|
|
|
|
|| row.minimum_payment != null
|
|
|
|
|
|
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function FilterChip({ active, children, onClick }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'h-8 rounded-full border px-3 text-xs font-medium transition-colors',
|
|
|
|
|
|
active
|
|
|
|
|
|
? 'border-primary/50 bg-primary/15 text-primary'
|
|
|
|
|
|
: 'border-border/70 bg-card/70 text-muted-foreground hover:bg-accent hover:text-foreground',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
// ── Summary cards ──────────────────────────────────────────────────────────
|
|
|
|
|
|
const CARD_DEFS = {
|
2026-05-04 20:12:57 -05:00
|
|
|
|
starting: {
|
|
|
|
|
|
label: 'Starting',
|
2026-05-03 19:51:57 -05:00
|
|
|
|
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',
|
2026-05-28 19:40:55 -05:00
|
|
|
|
valueClass: 'text-emerald-600 dark:text-emerald-200',
|
2026-05-03 19:51:57 -05:00
|
|
|
|
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,
|
2026-05-28 19:30:46 -05:00
|
|
|
|
bar: 'from-rose-400 to-orange-300',
|
|
|
|
|
|
glow: 'shadow-[0_4px_20px_rgba(251,113,133,0.10)]',
|
|
|
|
|
|
borderActive: 'border-rose-400/35',
|
2026-05-28 19:40:55 -05:00
|
|
|
|
valueClass: 'text-red-500 dark:text-rose-200',
|
2026-05-03 19:51:57 -05:00
|
|
|
|
activateWhen: (v) => v > 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-10 01:22:51 -05:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 22:45:38 -05:00
|
|
|
|
function SummaryCard({ type, value, onEdit, hint, label }) {
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const def = CARD_DEFS[type];
|
|
|
|
|
|
const isActive = def.activateWhen(value || 0);
|
|
|
|
|
|
const Icon = def.icon;
|
2026-05-15 22:45:38 -05:00
|
|
|
|
const displayLabel = label || def.label;
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={cn(
|
2026-05-28 19:58:01 -05:00
|
|
|
|
'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',
|
2026-05-03 19:51:57 -05:00
|
|
|
|
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">
|
2026-05-15 22:45:38 -05:00
|
|
|
|
{displayLabel}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</p>
|
2026-05-04 20:12:57 -05:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<p className={cn(
|
|
|
|
|
|
'text-[1.75rem] font-bold tracking-tight font-mono leading-none',
|
|
|
|
|
|
isActive ? def.valueClass : 'text-foreground',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{fmt(value)}
|
|
|
|
|
|
</p>
|
2026-05-04 20:12:57 -05:00
|
|
|
|
{hint && <p className="mt-2 text-[11px] text-muted-foreground">{hint}</p>}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 01:22:51 -05:00
|
|
|
|
function TrendCard({ trend }) {
|
|
|
|
|
|
if (!trend) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-28 19:58:01 -05:00
|
|
|
|
<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">
|
2026-05-10 01:22:51 -05:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
// ── Status badge ───────────────────────────────────────────────────────────
|
2026-05-09 13:03:36 -05:00
|
|
|
|
const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }) {
|
|
|
|
|
|
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
|
|
|
|
|
|
|
|
|
|
|
|
const isSkipped = status === 'skipped';
|
2026-05-28 23:42:46 -05:00
|
|
|
|
const isUrgent = status === 'late' || status === 'missed';
|
2026-05-09 13:03:36 -05:00
|
|
|
|
const canClick = clickable && !isSkipped && !loading;
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
return (
|
2026-05-09 13:03:36 -05:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={!canClick || loading}
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'inline-flex items-center px-2.5 py-0.5 text-[11px] rounded-md font-semibold',
|
|
|
|
|
|
'uppercase tracking-wide whitespace-nowrap',
|
|
|
|
|
|
'transition-all duration-150',
|
2026-05-28 23:42:46 -05:00
|
|
|
|
isUrgent && 'gap-1.5 px-2.5 py-1 text-xs',
|
2026-05-09 13:03:36 -05:00
|
|
|
|
canClick && 'cursor-pointer hover:scale-105 hover:shadow-sm',
|
|
|
|
|
|
canClick && status === 'paid' && 'hover:bg-red-500/20 hover:text-red-600 hover:border-red-500/40',
|
|
|
|
|
|
canClick && status !== 'paid' && 'hover:bg-emerald-500/20 hover:text-emerald-600 hover:border-emerald-500/40',
|
|
|
|
|
|
loading && 'opacity-60 cursor-wait',
|
|
|
|
|
|
meta.cls,
|
|
|
|
|
|
)}
|
|
|
|
|
|
title={canClick ? (status === 'paid' || status === 'autodraft' ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
|
|
|
|
|
{meta.label}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
2026-05-28 23:42:46 -05:00
|
|
|
|
<>
|
|
|
|
|
|
{isUrgent && <AlertCircle className="h-3.5 w-3.5" />}
|
|
|
|
|
|
{meta.label}
|
|
|
|
|
|
</>
|
2026-05-09 13:03:36 -05:00
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
);
|
2026-05-09 13:03:36 -05:00
|
|
|
|
});
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
function AutopaySuggestionActions({ row, loading, onConfirm, onDismiss, compact = false }) {
|
|
|
|
|
|
const suggestion = row.autopay_suggestion;
|
|
|
|
|
|
if (!suggestion) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const title = `${fmt(suggestion.amount)} due ${fmtDate(suggestion.paid_date)}`;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={cn(
|
|
|
|
|
|
'flex items-center gap-1.5',
|
|
|
|
|
|
compact ? 'w-full flex-wrap justify-end sm:w-auto' : 'justify-end',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'inline-flex min-w-0 items-center gap-1.5 rounded-md border border-sky-500/20 bg-sky-500/10',
|
|
|
|
|
|
'px-2 py-1 text-xs font-medium text-sky-600 dark:text-sky-300',
|
|
|
|
|
|
compact ? 'mr-auto' : 'max-w-32',
|
|
|
|
|
|
)}
|
|
|
|
|
|
title={`Autopay suggested: ${title}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Clock className="h-3 w-3 shrink-0" />
|
|
|
|
|
|
<span className="truncate">{compact ? `Suggested ${fmt(suggestion.amount)}` : 'Suggested'}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
disabled={loading}
|
|
|
|
|
|
onClick={onDismiss}
|
|
|
|
|
|
className="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
|
|
|
|
|
title={`Dismiss suggested autopay payment for ${title}`}
|
|
|
|
|
|
aria-label={`Dismiss suggested autopay payment for ${row.name}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
disabled={loading}
|
|
|
|
|
|
onClick={onConfirm}
|
|
|
|
|
|
className="h-8 w-8"
|
|
|
|
|
|
title={`Confirm suggested autopay payment for ${title}`}
|
|
|
|
|
|
aria-label={`Confirm suggested autopay payment for ${row.name}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
// ── Inline-editable payment cell ───────────────────────────────────────────
|
|
|
|
|
|
// `threshold` = actual_amount ?? expected_amount for this bill/month
|
|
|
|
|
|
function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) {
|
|
|
|
|
|
const [editing, setEditing] = useState(false);
|
|
|
|
|
|
const [value, setValue] = useState('');
|
|
|
|
|
|
const inputRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
|
|
const displayVal = field === 'amount'
|
|
|
|
|
|
? (row.total_paid > 0 ? fmt(row.total_paid) : '—')
|
|
|
|
|
|
: (row.last_paid_date ? fmtDate(row.last_paid_date) : '—');
|
|
|
|
|
|
|
|
|
|
|
|
const isEmpty = field === 'amount' ? row.total_paid <= 0 : !row.last_paid_date;
|
|
|
|
|
|
// Mismatch when paid amount differs from the effective threshold for this month
|
|
|
|
|
|
const mismatch = field === 'amount' && row.total_paid > 0 && row.total_paid !== threshold;
|
|
|
|
|
|
|
|
|
|
|
|
function startEdit() {
|
|
|
|
|
|
if (editing) return;
|
|
|
|
|
|
setValue(field === 'amount'
|
|
|
|
|
|
? (row.total_paid > 0 ? String(row.total_paid) : '')
|
|
|
|
|
|
: (row.last_paid_date || ''));
|
|
|
|
|
|
setEditing(true);
|
|
|
|
|
|
setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function commit() {
|
|
|
|
|
|
setEditing(false);
|
|
|
|
|
|
const val = value.trim();
|
|
|
|
|
|
if (!val) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (row.payments && row.payments.length > 0) {
|
|
|
|
|
|
const update = {};
|
|
|
|
|
|
if (field === 'amount') update.amount = parseFloat(val);
|
|
|
|
|
|
if (field === 'date') update.paid_date = val;
|
|
|
|
|
|
await api.updatePayment(row.payments[0].id, update);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await api.createPayment({
|
|
|
|
|
|
bill_id: row.id,
|
|
|
|
|
|
amount: field === 'amount' ? parseFloat(val) : threshold,
|
|
|
|
|
|
paid_date: field === 'date' ? val : defaultPaymentDate,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
toast.success('Saved');
|
|
|
|
|
|
refresh();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onKeyDown(e) {
|
|
|
|
|
|
if (e.key === 'Enter') inputRef.current?.blur();
|
|
|
|
|
|
if (e.key === 'Escape') { setValue(''); setEditing(false); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (editing) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Input
|
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
|
type={field === 'date' ? 'date' : 'number'}
|
|
|
|
|
|
step={field === 'amount' ? '0.01' : undefined}
|
|
|
|
|
|
min={field === 'amount' ? '0' : undefined}
|
|
|
|
|
|
value={value}
|
|
|
|
|
|
onChange={e => setValue(e.target.value)}
|
|
|
|
|
|
onBlur={commit}
|
|
|
|
|
|
onKeyDown={onKeyDown}
|
|
|
|
|
|
className="h-7 w-28 text-right font-mono text-sm bg-background/80 border-border/60"
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span
|
|
|
|
|
|
onClick={startEdit}
|
|
|
|
|
|
title={`Click to edit ${field === 'amount' ? 'payment amount' : 'paid date'}`}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'cursor-pointer rounded-md px-1.5 py-0.5 text-sm font-mono',
|
|
|
|
|
|
'transition-all duration-150 hover:bg-accent hover:ring-1 hover:ring-border',
|
|
|
|
|
|
isEmpty && 'text-muted-foreground',
|
|
|
|
|
|
mismatch && 'text-amber-500',
|
|
|
|
|
|
!isEmpty && !mismatch && 'text-emerald-500',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{displayVal}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
function paymentSummary(row, threshold) {
|
|
|
|
|
|
const target = Number(threshold) || 0;
|
|
|
|
|
|
const paid = Number(row.total_paid) || 0;
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const paidTowardDue = Number.isFinite(Number(row.paid_toward_due))
|
|
|
|
|
|
? Number(row.paid_toward_due)
|
|
|
|
|
|
: Math.min(paid, target);
|
|
|
|
|
|
const overpaid = Number.isFinite(Number(row.overpaid_amount))
|
|
|
|
|
|
? Number(row.overpaid_amount)
|
|
|
|
|
|
: Math.max(paid - target, 0);
|
|
|
|
|
|
const remaining = Math.max(target - paidTowardDue, 0);
|
|
|
|
|
|
const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0;
|
2026-05-16 15:38:28 -05:00
|
|
|
|
return {
|
|
|
|
|
|
target,
|
|
|
|
|
|
paid,
|
2026-05-16 20:26:09 -05:00
|
|
|
|
paidTowardDue,
|
|
|
|
|
|
overpaid,
|
2026-05-16 15:38:28 -05:00
|
|
|
|
remaining,
|
|
|
|
|
|
percent,
|
|
|
|
|
|
count: Array.isArray(row.payments) ? row.payments.length : 0,
|
|
|
|
|
|
partial: paid > 0 && remaining > 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 02:09:49 -05:00
|
|
|
|
function PaymentProgress({ row, threshold, onOpen, onMarkFullAmount, compact = false }) {
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const summary = paymentSummary(row, threshold);
|
|
|
|
|
|
const barTone = summary.remaining === 0
|
|
|
|
|
|
? 'bg-emerald-500'
|
|
|
|
|
|
: summary.paid > 0
|
|
|
|
|
|
? 'bg-amber-500'
|
|
|
|
|
|
: 'bg-muted-foreground/40';
|
|
|
|
|
|
|
2026-05-28 01:38:18 -05:00
|
|
|
|
const amountLabel = (() => {
|
|
|
|
|
|
if (summary.paid === 0) return '—';
|
|
|
|
|
|
if (summary.overpaid > 0) return `${fmt(summary.paidTowardDue)} · overpaid`;
|
|
|
|
|
|
if (summary.remaining > 0) return `${fmt(summary.paidTowardDue)} paid`;
|
|
|
|
|
|
return fmt(summary.paidTowardDue);
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
2026-05-28 02:09:49 -05:00
|
|
|
|
const showQuickFix = onMarkFullAmount && summary.partial && summary.paid > 0;
|
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
return (
|
2026-05-28 02:09:49 -05:00
|
|
|
|
<div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onOpen}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'w-full rounded-md text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
|
|
|
|
|
compact ? 'p-2' : 'px-2 py-1.5',
|
2026-05-16 15:38:28 -05:00
|
|
|
|
)}
|
2026-05-28 02:09:49 -05:00
|
|
|
|
title="View payment history"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center justify-between gap-2 text-xs">
|
2026-05-28 20:14:00 -05:00
|
|
|
|
<span className={cn('tracker-number text-[13px] font-semibold', summary.paid > 0 ? 'text-emerald-300' : 'text-muted-foreground/85')}>
|
2026-05-28 02:09:49 -05:00
|
|
|
|
{amountLabel}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{summary.count > 1 && (
|
|
|
|
|
|
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
|
|
|
|
{summary.count}×
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn('h-full rounded-full transition-all', barTone)}
|
|
|
|
|
|
style={{ width: `${summary.percent}%` }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{showQuickFix && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onMarkFullAmount}
|
|
|
|
|
|
className="mt-0.5 w-full rounded px-2 py-0.5 text-left text-[10px] text-muted-foreground/60 transition-colors hover:bg-accent/50 hover:text-foreground"
|
|
|
|
|
|
title={`Set ${fmt(summary.paidTowardDue)} as the full amount due this month`}
|
|
|
|
|
|
>
|
|
|
|
|
|
✓ {fmt(summary.paidTowardDue)} is the full amount
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-16 15:38:28 -05:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
|
function LowerThisMonthButton({ row, year, month, refresh, compact = false }) {
|
|
|
|
|
|
const threshold = rowThreshold(row);
|
|
|
|
|
|
const summary = paymentSummary(row, threshold);
|
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
if (row.is_skipped || !summary.partial) return null;
|
|
|
|
|
|
|
|
|
|
|
|
async function handleClick() {
|
|
|
|
|
|
setSaving(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.saveBillMonthlyState(row.id, {
|
|
|
|
|
|
year,
|
|
|
|
|
|
month,
|
|
|
|
|
|
actual_amount: summary.paid,
|
|
|
|
|
|
notes: row.monthly_notes || null,
|
|
|
|
|
|
is_skipped: row.is_skipped,
|
|
|
|
|
|
});
|
|
|
|
|
|
toast.success(`${MONTHS[month - 1]} amount set to ${fmt(summary.paid)}`);
|
|
|
|
|
|
refresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to update monthly amount');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
disabled={saving}
|
|
|
|
|
|
onClick={handleClick}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'h-7 px-2.5 text-xs font-semibold text-amber-600 hover:bg-amber-500/10 hover:text-amber-700',
|
|
|
|
|
|
'dark:text-amber-400 dark:hover:text-amber-300',
|
|
|
|
|
|
compact && 'h-8',
|
|
|
|
|
|
)}
|
|
|
|
|
|
title={`Set ${MONTHS[month - 1]} amount to ${fmt(summary.paid)} because this bill was lower this month`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{saving ? 'Saving...' : 'Bill was lower'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }) {
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const summary = paymentSummary(row, threshold);
|
|
|
|
|
|
const [amount, setAmount] = useState(String(summary.remaining || summary.target || ''));
|
|
|
|
|
|
const [date, setDate] = useState(defaultPaymentDate);
|
|
|
|
|
|
const [method, setMethod] = useState(METHOD_NONE);
|
|
|
|
|
|
const [notes, setNotes] = useState('');
|
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
|
const [editPayment, setEditPayment] = useState(null);
|
|
|
|
|
|
const payments = [...(row.payments || [])].sort((a, b) => String(b.paid_date).localeCompare(String(a.paid_date)));
|
|
|
|
|
|
|
|
|
|
|
|
async function handleAdd(e) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const parsedAmount = parseFloat(amount);
|
|
|
|
|
|
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
|
|
|
|
|
toast.error('Enter a positive payment amount');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!date) {
|
|
|
|
|
|
toast.error('Choose a payment date');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setBusy(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.createPayment({
|
|
|
|
|
|
bill_id: row.id,
|
|
|
|
|
|
amount: parsedAmount,
|
|
|
|
|
|
paid_date: date,
|
|
|
|
|
|
method: method === METHOD_NONE ? null : method,
|
|
|
|
|
|
notes: notes || null,
|
|
|
|
|
|
});
|
|
|
|
|
|
toast.success('Partial payment added');
|
|
|
|
|
|
onSaved?.();
|
|
|
|
|
|
onClose?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to add payment');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Dialog open onOpenChange={value => { if (!value) onClose(); }}>
|
|
|
|
|
|
<DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl">
|
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle className="text-base font-semibold tracking-tight">{row.name} Payments</DialogTitle>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="rounded-lg border border-border/60 bg-muted/25 p-3">
|
|
|
|
|
|
<PaymentProgress row={row} threshold={threshold} onOpen={() => {}} />
|
2026-05-16 20:26:09 -05:00
|
|
|
|
<div className="mt-2 flex justify-end">
|
|
|
|
|
|
<LowerThisMonthButton
|
|
|
|
|
|
row={row}
|
|
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
|
|
|
|
|
refresh={onSaved}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-05-16 15:38:28 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
|
|
|
|
|
|
<div className="rounded-lg border border-border/60 bg-background/45 p-3">
|
|
|
|
|
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Payment History</p>
|
|
|
|
|
|
{payments.length > 0 ? (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{payments.map(payment => (
|
|
|
|
|
|
<div key={payment.id} className="flex items-center justify-between gap-3 rounded-md border border-border/50 bg-card/60 px-3 py-2">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<p className="font-mono text-sm font-semibold text-foreground">{fmt(payment.amount)}</p>
|
|
|
|
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
|
|
|
|
{fmtDate(payment.paid_date)}
|
|
|
|
|
|
{payment.method ? ` · ${payment.method}` : ''}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{payment.notes && (
|
|
|
|
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">{payment.notes}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button type="button" size="sm" variant="ghost" className="h-8 px-2.5 text-xs" onClick={() => setEditPayment(payment)}>
|
|
|
|
|
|
Edit
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
No payments recorded for this month.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleAdd} className="rounded-lg border border-border/60 bg-background/45 p-3">
|
|
|
|
|
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Add Partial Payment</p>
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<Label htmlFor={`partial-amount-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Amount</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id={`partial-amount-${row.id}`}
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
step="0.01"
|
|
|
|
|
|
value={amount}
|
|
|
|
|
|
onChange={e => setAmount(e.target.value)}
|
|
|
|
|
|
className="font-mono bg-background/70 border-border/60"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<Label htmlFor={`partial-date-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Paid Date</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id={`partial-date-${row.id}`}
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
value={date}
|
|
|
|
|
|
onChange={e => setDate(e.target.value)}
|
|
|
|
|
|
className="font-mono bg-background/70 border-border/60"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Method</Label>
|
|
|
|
|
|
<Select value={method} onValueChange={setMethod}>
|
|
|
|
|
|
<SelectTrigger className="bg-background/70 border-border/60">
|
|
|
|
|
|
<SelectValue placeholder="—" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value={METHOD_NONE}>—</SelectItem>
|
|
|
|
|
|
<SelectItem value="bank">Bank Transfer</SelectItem>
|
|
|
|
|
|
<SelectItem value="card">Card</SelectItem>
|
|
|
|
|
|
<SelectItem value="autopay">Autopay</SelectItem>
|
|
|
|
|
|
<SelectItem value="check">Check</SelectItem>
|
|
|
|
|
|
<SelectItem value="cash">Cash</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
<Label htmlFor={`partial-notes-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id={`partial-notes-${row.id}`}
|
|
|
|
|
|
value={notes}
|
|
|
|
|
|
onChange={e => setNotes(e.target.value)}
|
|
|
|
|
|
className="bg-background/70 border-border/60"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button type="submit" disabled={busy} className="w-full gap-2">
|
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
|
{busy ? 'Adding...' : 'Add Payment'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
|
|
{editPayment && (
|
|
|
|
|
|
<PaymentModal
|
|
|
|
|
|
payment={editPayment}
|
|
|
|
|
|
onClose={() => setEditPayment(null)}
|
|
|
|
|
|
onSave={() => {
|
|
|
|
|
|
onSaved?.();
|
|
|
|
|
|
setEditPayment(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
|
// ── Notes cell (monthly state notes) ─────────────────────────────────────
|
|
|
|
|
|
// Shows the monthly state notes for this bill in the current month.
|
|
|
|
|
|
// Notes are per-month, not per-bill - each month has its own notes field.
|
2026-05-03 19:51:57 -05:00
|
|
|
|
function NotesCell({ row, refresh }) {
|
2026-05-09 13:03:36 -05:00
|
|
|
|
// Monthly notes - the per-month notes stored in monthly_bill_state
|
|
|
|
|
|
const savedNote = row.monthly_notes || '';
|
|
|
|
|
|
const [value, setValue] = useState(savedNote);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
async function handleBlur() {
|
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
|
if (trimmed === savedNote) return;
|
2026-05-09 13:03:36 -05:00
|
|
|
|
|
|
|
|
|
|
// Need year and month to save to monthly_bill_state
|
|
|
|
|
|
// These should be passed via row props from the parent
|
|
|
|
|
|
const year = row.year;
|
|
|
|
|
|
const month = row.month;
|
|
|
|
|
|
|
|
|
|
|
|
if (!year || !month) {
|
|
|
|
|
|
toast.error('Cannot save notes without year/month context');
|
|
|
|
|
|
setValue(savedNote);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-09 13:03:36 -05:00
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
setSaving(true);
|
|
|
|
|
|
try {
|
2026-05-09 13:03:36 -05:00
|
|
|
|
await api.saveBillMonthlyState(row.id, {
|
|
|
|
|
|
year,
|
|
|
|
|
|
month,
|
|
|
|
|
|
notes: trimmed || null,
|
|
|
|
|
|
is_skipped: row.is_skipped,
|
|
|
|
|
|
actual_amount: row.actual_amount,
|
|
|
|
|
|
});
|
2026-05-03 19:51:57 -05:00
|
|
|
|
refresh();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message);
|
|
|
|
|
|
setValue(savedNote);
|
|
|
|
|
|
} finally { setSaving(false); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={value}
|
|
|
|
|
|
onChange={e => setValue(e.target.value)}
|
|
|
|
|
|
onBlur={handleBlur}
|
|
|
|
|
|
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
|
2026-05-09 13:03:36 -05:00
|
|
|
|
placeholder='Add monthly notes…'
|
|
|
|
|
|
disabled={saving}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
className={cn(
|
|
|
|
|
|
'w-full bg-transparent text-sm placeholder:text-muted-foreground/40',
|
|
|
|
|
|
'border-0 outline-none ring-0',
|
|
|
|
|
|
'text-muted-foreground focus:text-foreground',
|
|
|
|
|
|
'transition-colors duration-150',
|
|
|
|
|
|
'disabled:cursor-not-allowed disabled:opacity-40',
|
|
|
|
|
|
value && 'text-foreground/80',
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Table row ──────────────────────────────────────────────────────────────
|
2026-05-29 21:16:13 -05:00
|
|
|
|
function Row({ row, year, month, refresh, index, onEditBill }) {
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const amountRef = useRef(null);
|
|
|
|
|
|
const [editPayment, setEditPayment] = useState(null);
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const [showMbs, setShowMbs] = useState(false);
|
2026-05-16 10:34:32 -05:00
|
|
|
|
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
2026-05-09 13:03:36 -05:00
|
|
|
|
const [loading, setLoading] = useState(false);
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
2026-05-28 02:09:49 -05:00
|
|
|
|
const [optimisticActual, setOptimisticActual] = useState(undefined);
|
|
|
|
|
|
const [showUpdateNudge, setShowUpdateNudge] = useState(false);
|
|
|
|
|
|
const [nudgeAmount, setNudgeAmount] = useState(null);
|
|
|
|
|
|
const [, startTransition] = useTransition();
|
|
|
|
|
|
|
2026-05-28 02:53:35 -05:00
|
|
|
|
const [editingExpected, setEditingExpected] = useState(false);
|
|
|
|
|
|
const [expectedDraft, setExpectedDraft] = useState('');
|
|
|
|
|
|
const [editingDue, setEditingDue] = useState(false);
|
|
|
|
|
|
const [dueDraft, setDueDraft] = useState('');
|
|
|
|
|
|
|
2026-05-28 02:09:49 -05:00
|
|
|
|
// Effective amount threshold: optimistic override → monthly override → template default.
|
|
|
|
|
|
const effectiveActual = optimisticActual !== undefined ? optimisticActual : row.actual_amount;
|
|
|
|
|
|
const threshold = effectiveActual != null ? effectiveActual : row.expected_amount;
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const isSkipped = !!row.is_skipped;
|
|
|
|
|
|
const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
// Paid when total payments >= effective threshold
|
|
|
|
|
|
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
|
|
|
|
|
|
const summary = paymentSummary(row, threshold);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
// Effective status to show:
|
|
|
|
|
|
// skipped > paid (threshold-based) > backend status
|
|
|
|
|
|
const effectiveStatus = isSkipped
|
|
|
|
|
|
? 'skipped'
|
|
|
|
|
|
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
|
|
|
|
|
|
? 'paid'
|
|
|
|
|
|
: row.status;
|
|
|
|
|
|
|
|
|
|
|
|
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
|
|
|
|
|
|
|
|
|
|
|
|
async function handleQuickPay() {
|
|
|
|
|
|
const val = parseFloat(amountRef.current?.value);
|
|
|
|
|
|
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
|
2026-05-16 15:38:28 -05:00
|
|
|
|
toast.success('Payment added');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
refresh();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 10:34:32 -05:00
|
|
|
|
async function performTogglePaid() {
|
2026-05-10 14:36:59 -05:00
|
|
|
|
setLoading?.(true);
|
|
|
|
|
|
try {
|
2026-05-10 17:28:26 -05:00
|
|
|
|
const result = await api.togglePaid(row.id, {
|
2026-05-10 14:36:59 -05:00
|
|
|
|
amount: isPaid ? undefined : threshold,
|
2026-05-11 11:56:49 -05:00
|
|
|
|
year: year,
|
|
|
|
|
|
month: month,
|
2026-05-10 14:36:59 -05:00
|
|
|
|
});
|
2026-05-16 10:34:32 -05:00
|
|
|
|
if (isPaid && result.paymentId) {
|
|
|
|
|
|
toast.success('Payment moved to recovery', {
|
|
|
|
|
|
action: {
|
|
|
|
|
|
label: 'Undo',
|
|
|
|
|
|
onClick: async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.restorePayment(result.paymentId);
|
|
|
|
|
|
toast.success('Payment restored');
|
|
|
|
|
|
refresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to restore payment');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.success('Payment recorded');
|
|
|
|
|
|
}
|
2026-05-10 14:36:59 -05:00
|
|
|
|
refresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to toggle payment status');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading?.(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 10:34:32 -05:00
|
|
|
|
function handleTogglePaid() {
|
|
|
|
|
|
if (isPaid) {
|
|
|
|
|
|
setConfirmUnpay(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
performTogglePaid();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 02:09:49 -05:00
|
|
|
|
async function handleMarkFullAmount() {
|
|
|
|
|
|
const newActual = summary.paidTowardDue;
|
|
|
|
|
|
setOptimisticActual(newActual);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.saveBillMonthlyState(row.id, {
|
|
|
|
|
|
year, month,
|
|
|
|
|
|
actual_amount: newActual,
|
|
|
|
|
|
notes: row.monthly_notes || null,
|
|
|
|
|
|
is_skipped: row.is_skipped,
|
|
|
|
|
|
});
|
|
|
|
|
|
setNudgeAmount(newActual);
|
|
|
|
|
|
setShowUpdateNudge(true);
|
|
|
|
|
|
refresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setOptimisticActual(undefined);
|
|
|
|
|
|
toast.error(err.message || 'Failed to update amount');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleUpdateTemplate() {
|
|
|
|
|
|
const amount = nudgeAmount;
|
|
|
|
|
|
setShowUpdateNudge(false);
|
|
|
|
|
|
startTransition(async () => {
|
|
|
|
|
|
try {
|
2026-05-28 02:53:59 -05:00
|
|
|
|
await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: amount });
|
2026-05-28 02:09:49 -05:00
|
|
|
|
toast.success(`Default updated to ${fmt(amount)}`);
|
|
|
|
|
|
refresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to update default');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleApplySuggestion(amount) {
|
|
|
|
|
|
setOptimisticActual(amount);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.saveBillMonthlyState(row.id, {
|
|
|
|
|
|
year, month,
|
|
|
|
|
|
actual_amount: amount,
|
|
|
|
|
|
notes: row.monthly_notes || null,
|
|
|
|
|
|
is_skipped: row.is_skipped,
|
|
|
|
|
|
});
|
|
|
|
|
|
refresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setOptimisticActual(undefined);
|
|
|
|
|
|
toast.error(err.message || 'Failed to apply suggestion');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 02:53:35 -05:00
|
|
|
|
async function handleSaveExpected() {
|
|
|
|
|
|
setEditingExpected(false);
|
|
|
|
|
|
const val = parseFloat(expectedDraft);
|
|
|
|
|
|
if (!isFinite(val) || val < 0) return;
|
|
|
|
|
|
const current = effectiveActual ?? row.expected_amount;
|
|
|
|
|
|
if (val === current) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (effectiveActual != null) {
|
|
|
|
|
|
setOptimisticActual(val);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.saveBillMonthlyState(row.id, {
|
|
|
|
|
|
year, month,
|
|
|
|
|
|
actual_amount: val,
|
|
|
|
|
|
notes: row.monthly_notes || null,
|
|
|
|
|
|
is_skipped: row.is_skipped,
|
|
|
|
|
|
});
|
|
|
|
|
|
refresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setOptimisticActual(undefined);
|
|
|
|
|
|
toast.error(err.message || 'Failed to update amount');
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: val });
|
|
|
|
|
|
refresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to update expected amount');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSaveDue() {
|
|
|
|
|
|
setEditingDue(false);
|
|
|
|
|
|
const day = parseInt(dueDraft, 10);
|
|
|
|
|
|
if (!isFinite(day) || day < 1 || day > 31) return;
|
|
|
|
|
|
if (day === row.due_day) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.updateBill(row.id, { name: row.name, due_day: day, expected_amount: row.expected_amount });
|
|
|
|
|
|
refresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to update due date');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
async function handleConfirmSuggestion() {
|
|
|
|
|
|
setSuggestionLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await api.confirmAutopaySuggestion(row.id, { year, month });
|
|
|
|
|
|
toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
|
|
|
|
|
|
refresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to confirm autopay suggestion');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSuggestionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDismissSuggestion() {
|
|
|
|
|
|
setSuggestionLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.dismissAutopaySuggestion(row.id, { year, month });
|
|
|
|
|
|
toast.success('Autopay suggestion dismissed');
|
|
|
|
|
|
refresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to dismiss autopay suggestion');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSuggestionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<TableRow
|
|
|
|
|
|
className={cn(
|
2026-05-28 19:58:01 -05:00
|
|
|
|
'group border-border/65 transition-colors duration-150',
|
2026-05-03 19:51:57 -05:00
|
|
|
|
isSkipped ? 'opacity-40' : 'hover:bg-accent/50',
|
|
|
|
|
|
rowBg,
|
|
|
|
|
|
)}
|
|
|
|
|
|
style={{ animationDelay: `${index * 40}ms` }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* Bill name + category + monthly notes (if set) */}
|
|
|
|
|
|
<TableCell className="w-[18%] py-3">
|
|
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
|
|
|
|
<div>
|
2026-05-14 01:17:05 -05:00
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
{row.website ? (
|
|
|
|
|
|
<a
|
|
|
|
|
|
href={row.website}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
|
className={cn(
|
2026-05-28 20:14:00 -05:00
|
|
|
|
'text-[15px] font-semibold leading-tight text-foreground transition-colors',
|
2026-05-14 01:17:05 -05:00
|
|
|
|
'hover:underline decoration-muted-foreground/50 underline-offset-2',
|
|
|
|
|
|
isSkipped && 'line-through',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{row.name}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : (
|
2026-05-28 20:14:00 -05:00
|
|
|
|
<span className={cn('text-[15px] font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
|
2026-05-14 01:17:05 -05:00
|
|
|
|
{row.name}
|
|
|
|
|
|
</span>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
)}
|
2026-05-28 23:50:03 -05:00
|
|
|
|
{row.autopay_enabled && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="inline-flex shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-sky-600 dark:text-sky-300"
|
|
|
|
|
|
title="Autopay"
|
|
|
|
|
|
>
|
|
|
|
|
|
AP
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-05-29 19:21:46 -05:00
|
|
|
|
{row.is_subscription && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
|
|
|
|
|
|
title="Subscription"
|
|
|
|
|
|
>
|
2026-05-29 20:33:01 -05:00
|
|
|
|
S
|
2026-05-29 19:21:46 -05:00
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-05-14 01:17:05 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
size="icon" variant="ghost"
|
|
|
|
|
|
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
|
|
|
|
|
title="Edit bill"
|
|
|
|
|
|
onClick={() => onEditBill?.(row)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Pencil className="h-3 w-3" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
{row.category_name && (
|
2026-05-28 20:14:00 -05:00
|
|
|
|
<p className="mt-0.5 text-xs text-muted-foreground/85">{row.category_name}</p>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
)}
|
|
|
|
|
|
{/* Monthly notes shown inline under the bill name */}
|
|
|
|
|
|
{row.monthly_notes && (
|
|
|
|
|
|
<p className="text-[11px] text-amber-500/80 mt-0.5 italic truncate max-w-[140px]"
|
|
|
|
|
|
title={row.monthly_notes}>
|
|
|
|
|
|
{row.monthly_notes}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Due */}
|
2026-05-28 20:14:00 -05:00
|
|
|
|
<TableCell className="tracker-number w-[10%] py-3 text-[13px] font-medium text-foreground/75">
|
2026-05-28 02:53:35 -05:00
|
|
|
|
{editingDue ? (
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="1" max="31"
|
|
|
|
|
|
value={dueDraft}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
onChange={e => setDueDraft(e.target.value)}
|
|
|
|
|
|
onBlur={handleSaveDue}
|
|
|
|
|
|
onKeyDown={e => {
|
|
|
|
|
|
if (e.key === 'Enter') e.currentTarget.blur();
|
|
|
|
|
|
if (e.key === 'Escape') { setEditingDue(false); }
|
|
|
|
|
|
}}
|
2026-05-28 20:14:00 -05:00
|
|
|
|
className="tracker-number w-12 rounded border border-border bg-transparent px-1 py-0.5 text-sm font-medium text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
|
2026-05-28 02:53:35 -05:00
|
|
|
|
title="Day of month (1–31)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => { setDueDraft(String(row.due_day)); setEditingDue(true); }}
|
|
|
|
|
|
className="rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-foreground"
|
|
|
|
|
|
title="Click to edit due day"
|
|
|
|
|
|
>
|
|
|
|
|
|
{fmtDate(row.due_date)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
|
2026-05-28 20:14:00 -05:00
|
|
|
|
<TableCell className="tracker-number w-[10%] py-3 text-right text-[13px] font-semibold">
|
2026-05-28 02:53:35 -05:00
|
|
|
|
{editingExpected ? (
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0" step="0.01"
|
|
|
|
|
|
value={expectedDraft}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
onChange={e => setExpectedDraft(e.target.value)}
|
|
|
|
|
|
onBlur={handleSaveExpected}
|
|
|
|
|
|
onKeyDown={e => {
|
|
|
|
|
|
if (e.key === 'Enter') e.currentTarget.blur();
|
|
|
|
|
|
if (e.key === 'Escape') { setEditingExpected(false); }
|
|
|
|
|
|
}}
|
2026-05-28 20:14:00 -05:00
|
|
|
|
className="tracker-number w-24 rounded border border-border bg-transparent px-1 py-0.5 text-right text-sm font-semibold text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
|
2026-05-28 02:53:35 -05:00
|
|
|
|
/>
|
|
|
|
|
|
) : effectiveActual != null ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => { setExpectedDraft(String(effectiveActual)); setEditingExpected(true); }}
|
2026-05-28 20:14:00 -05:00
|
|
|
|
className="rounded px-1 py-0.5 text-amber-300 transition-colors hover:bg-accent"
|
2026-05-28 02:53:35 -05:00
|
|
|
|
title={`Monthly override — click to edit. Template default: ${fmt(row.expected_amount)}`}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
>
|
2026-05-28 02:09:49 -05:00
|
|
|
|
{fmt(effectiveActual)}
|
2026-05-28 02:53:35 -05:00
|
|
|
|
</button>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
) : (
|
2026-05-28 02:09:49 -05:00
|
|
|
|
<div className="flex flex-col items-end gap-0.5">
|
2026-05-28 02:53:35 -05:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => { setExpectedDraft(String(row.expected_amount)); setEditingExpected(true); }}
|
2026-05-28 20:14:00 -05:00
|
|
|
|
className="rounded px-1 py-0.5 text-foreground/85 transition-colors hover:bg-accent hover:text-foreground"
|
2026-05-28 02:53:35 -05:00
|
|
|
|
title="Click to edit expected amount"
|
|
|
|
|
|
>
|
|
|
|
|
|
{fmt(row.expected_amount)}
|
|
|
|
|
|
</button>
|
2026-05-28 02:09:49 -05:00
|
|
|
|
{row.amount_suggestion?.suggestion != null &&
|
|
|
|
|
|
Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => handleApplySuggestion(row.amount_suggestion.suggestion)}
|
|
|
|
|
|
className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
|
|
|
|
|
|
title={`Based on last ${row.amount_suggestion.months_used} months`}
|
|
|
|
|
|
>
|
|
|
|
|
|
~{fmt(row.amount_suggestion.suggestion)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
)}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
2026-05-10 00:52:23 -05:00
|
|
|
|
{/* Previous month paid */}
|
2026-05-29 21:16:13 -05:00
|
|
|
|
<TableCell className="tracker-number w-[10%] py-3 text-right text-[13px] font-medium text-muted-foreground/80">
|
2026-05-10 00:52:23 -05:00
|
|
|
|
{row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
{/* Amount paid — mismatch now compares against threshold */}
|
|
|
|
|
|
<TableCell className="w-[10%] py-3 text-right">
|
2026-05-16 15:38:28 -05:00
|
|
|
|
<PaymentProgress
|
2026-05-03 19:51:57 -05:00
|
|
|
|
row={row}
|
|
|
|
|
|
threshold={threshold}
|
2026-05-16 15:38:28 -05:00
|
|
|
|
onOpen={() => setPaymentLedgerOpen(true)}
|
2026-05-28 02:09:49 -05:00
|
|
|
|
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
/>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Paid date */}
|
2026-05-28 20:14:00 -05:00
|
|
|
|
<TableCell className="w-[10%] py-3 text-[13px] text-foreground/75">
|
2026-05-16 15:38:28 -05:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setPaymentLedgerOpen(true)}
|
2026-05-28 20:14:00 -05:00
|
|
|
|
className="tracker-number rounded-md px-1.5 py-0.5 font-medium transition-colors hover:bg-accent hover:text-foreground"
|
2026-05-16 15:38:28 -05:00
|
|
|
|
title="View payment history"
|
|
|
|
|
|
>
|
|
|
|
|
|
{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
|
|
|
|
|
|
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
|
|
|
|
|
|
</button>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
|
|
|
|
|
|
<TableCell className="w-[9%] py-3">
|
2026-05-09 13:03:36 -05:00
|
|
|
|
<StatusBadge
|
|
|
|
|
|
status={effectiveStatus}
|
|
|
|
|
|
clickable
|
2026-05-10 14:36:59 -05:00
|
|
|
|
onClick={() => {
|
2026-05-09 13:03:36 -05:00
|
|
|
|
if (effectiveStatus === 'skipped') return;
|
2026-05-10 14:36:59 -05:00
|
|
|
|
handleTogglePaid();
|
2026-05-09 13:03:36 -05:00
|
|
|
|
}}
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
/>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
|
|
|
|
|
<TableCell className="w-[10%] py-3 text-right">
|
|
|
|
|
|
<div className="flex items-center justify-end gap-1">
|
2026-05-28 02:09:49 -05:00
|
|
|
|
{showUpdateNudge ? (
|
|
|
|
|
|
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-1 duration-200">
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">Update default?</span>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
size="sm" variant="ghost"
|
2026-05-28 02:09:49 -05:00
|
|
|
|
onClick={handleUpdateTemplate}
|
|
|
|
|
|
className="h-6 px-2 text-[10px] font-semibold text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300"
|
2026-05-03 19:51:57 -05:00
|
|
|
|
>
|
2026-05-28 02:09:49 -05:00
|
|
|
|
{fmt(nudgeAmount)}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</Button>
|
2026-05-28 02:09:49 -05:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowUpdateNudge(false)}
|
style: global readability/theme pass
- Sharpened font stack in index.css, removed softer Georgia digit font for UI text/money
- Tuned dark-mode tokens: clearer foreground, brighter muted text, stronger borders, defined cards
- Updated UI primitives: cards, buttons, inputs, selects, tables, badges
- Cleaned up bills rows, mobile bill rows, tracker dismiss, snowball icons, summary/category/health/analytics money values, import/export status icons
- Reduced fuzzy uppercase label spacing globally
2026-05-28 23:18:14 -05:00
|
|
|
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
2026-05-28 02:09:49 -05:00
|
|
|
|
title="Dismiss"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
|
</button>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
2026-05-28 02:09:49 -05:00
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{hasAutopaySuggestion && (
|
|
|
|
|
|
<AutopaySuggestionActions
|
|
|
|
|
|
row={row}
|
|
|
|
|
|
loading={suggestionLoading}
|
|
|
|
|
|
onConfirm={handleConfirmSuggestion}
|
|
|
|
|
|
onDismiss={handleDismissSuggestion}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{/* Quick pay — hidden for skipped/paid bills */}
|
|
|
|
|
|
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
ref={amountRef}
|
|
|
|
|
|
type="number" min="0" step="0.01"
|
|
|
|
|
|
defaultValue={summary.remaining || threshold}
|
2026-05-28 20:14:00 -05:00
|
|
|
|
className="tracker-number h-7 w-20 text-right text-sm font-medium bg-background/50 border-border/50"
|
2026-05-28 02:09:49 -05:00
|
|
|
|
title="Payment amount"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm" variant="ghost"
|
|
|
|
|
|
onClick={handleQuickPay}
|
|
|
|
|
|
className="h-7 px-2.5 text-xs font-semibold text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:text-emerald-300 dark:hover:bg-emerald-500/10"
|
|
|
|
|
|
>
|
|
|
|
|
|
Add
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
|
{/* Notes cell (monthly state notes) */}
|
2026-05-29 21:16:13 -05:00
|
|
|
|
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
|
2026-05-09 13:03:36 -05:00
|
|
|
|
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
|
|
|
|
|
|
{editPayment && (
|
|
|
|
|
|
<PaymentModal
|
|
|
|
|
|
payment={editPayment}
|
|
|
|
|
|
onClose={() => setEditPayment(null)}
|
|
|
|
|
|
onSave={refresh}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
{paymentLedgerOpen && (
|
|
|
|
|
|
<PaymentLedgerDialog
|
|
|
|
|
|
row={row}
|
2026-05-16 20:26:09 -05:00
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
2026-05-16 15:38:28 -05:00
|
|
|
|
threshold={threshold}
|
|
|
|
|
|
defaultPaymentDate={defaultPaymentDate}
|
|
|
|
|
|
onClose={() => setPaymentLedgerOpen(false)}
|
|
|
|
|
|
onSaved={refresh}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
{showMbs && (
|
|
|
|
|
|
<MonthlyStateDialog
|
|
|
|
|
|
row={row}
|
|
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
|
|
|
|
|
open={showMbs}
|
|
|
|
|
|
onOpenChange={setShowMbs}
|
|
|
|
|
|
onSaved={refresh}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-05-10 14:36:59 -05:00
|
|
|
|
|
2026-05-16 10:34:32 -05:00
|
|
|
|
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
|
This removes the current payment record for this month and moves it into recovery.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
|
|
|
|
|
<AlertDialogAction
|
|
|
|
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
|
|
|
|
disabled={loading}
|
|
|
|
|
|
onClick={performTogglePaid}
|
|
|
|
|
|
>
|
|
|
|
|
|
{loading ? 'Removing...' : 'Remove Payment'}
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
|
function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
|
|
|
|
|
const amountRef = useRef(null);
|
|
|
|
|
|
const [editPayment, setEditPayment] = useState(null);
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const [showMbs, setShowMbs] = useState(false);
|
2026-05-16 10:34:32 -05:00
|
|
|
|
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
|
|
|
|
|
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
|
|
|
|
|
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
|
|
|
|
|
const isSkipped = !!row.is_skipped;
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
|
|
|
|
|
|
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
|
|
|
|
|
const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const effectiveStatus = isSkipped
|
|
|
|
|
|
? 'skipped'
|
|
|
|
|
|
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
|
|
|
|
|
|
? 'paid'
|
|
|
|
|
|
: row.status;
|
|
|
|
|
|
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
|
|
|
|
|
|
const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const summary = paymentSummary(row, threshold);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
|
|
|
|
|
async function handleQuickPay() {
|
|
|
|
|
|
const val = parseFloat(amountRef.current?.value);
|
|
|
|
|
|
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
|
2026-05-16 15:38:28 -05:00
|
|
|
|
toast.success('Payment added');
|
2026-05-04 13:14:32 -05:00
|
|
|
|
refresh();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 10:34:32 -05:00
|
|
|
|
async function performTogglePaid() {
|
2026-05-11 11:56:49 -05:00
|
|
|
|
try {
|
2026-05-16 10:34:32 -05:00
|
|
|
|
const result = await api.togglePaid(row.id, {
|
2026-05-11 11:56:49 -05:00
|
|
|
|
amount: isPaid ? undefined : threshold,
|
|
|
|
|
|
year: year,
|
|
|
|
|
|
month: month,
|
|
|
|
|
|
});
|
2026-05-16 10:34:32 -05:00
|
|
|
|
if (isPaid && result.paymentId) {
|
|
|
|
|
|
toast.success('Payment moved to recovery', {
|
|
|
|
|
|
action: {
|
|
|
|
|
|
label: 'Undo',
|
|
|
|
|
|
onClick: async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.restorePayment(result.paymentId);
|
|
|
|
|
|
toast.success('Payment restored');
|
|
|
|
|
|
refresh();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to restore payment');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.success('Payment recorded');
|
|
|
|
|
|
}
|
2026-05-11 11:56:49 -05:00
|
|
|
|
refresh();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to toggle payment status');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 10:34:32 -05:00
|
|
|
|
function handleTogglePaid() {
|
|
|
|
|
|
if (isPaid) {
|
|
|
|
|
|
setConfirmUnpay(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
performTogglePaid();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
async function handleConfirmSuggestion() {
|
|
|
|
|
|
setSuggestionLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await api.confirmAutopaySuggestion(row.id, { year, month });
|
|
|
|
|
|
toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
|
|
|
|
|
|
refresh();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to confirm autopay suggestion');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSuggestionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDismissSuggestion() {
|
|
|
|
|
|
setSuggestionLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.dismissAutopaySuggestion(row.id, { year, month });
|
|
|
|
|
|
toast.success('Autopay suggestion dismissed');
|
|
|
|
|
|
refresh();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to dismiss autopay suggestion');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSuggestionLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm',
|
|
|
|
|
|
'space-y-3 transition-colors',
|
|
|
|
|
|
isSkipped ? 'opacity-55' : rowBg,
|
|
|
|
|
|
)}
|
|
|
|
|
|
style={{ animationDelay: `${index * 40}ms` }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<div className="flex min-w-0 items-center gap-2">
|
2026-05-14 01:17:05 -05:00
|
|
|
|
{row.website ? (
|
|
|
|
|
|
<a
|
|
|
|
|
|
href={row.website}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
|
className={cn(
|
2026-05-28 20:14:00 -05:00
|
|
|
|
'min-w-0 truncate text-[15px] font-semibold leading-tight text-foreground',
|
2026-05-14 01:17:05 -05:00
|
|
|
|
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
|
|
|
|
|
isSkipped && 'line-through',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{row.name}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : (
|
2026-05-28 20:14:00 -05:00
|
|
|
|
<span className={cn('min-w-0 truncate text-[15px] font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
|
2026-05-14 01:17:05 -05:00
|
|
|
|
{row.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-05-28 23:50:03 -05:00
|
|
|
|
{row.autopay_enabled && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="inline-flex shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-sky-600 dark:text-sky-300"
|
|
|
|
|
|
title="Autopay"
|
|
|
|
|
|
>
|
|
|
|
|
|
AP
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-05-14 01:17:05 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
size="icon" variant="ghost"
|
|
|
|
|
|
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
2026-05-04 13:14:32 -05:00
|
|
|
|
title="Edit bill"
|
2026-05-14 01:17:05 -05:00
|
|
|
|
onClick={() => onEditBill?.(row)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
>
|
2026-05-14 01:17:05 -05:00
|
|
|
|
<Pencil className="h-3 w-3" />
|
|
|
|
|
|
</Button>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
{row.monthly_notes && (
|
|
|
|
|
|
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
|
|
|
|
|
|
{row.monthly_notes}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-11 11:56:49 -05:00
|
|
|
|
<StatusBadge status={effectiveStatus} clickable={!isSkipped} onClick={handleTogglePaid} />
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground sm:grid-cols-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
|
2026-05-28 20:14:00 -05:00
|
|
|
|
<p className="tracker-number mt-0.5 text-sm font-medium text-foreground/90">{fmtDate(row.due_date)}</p>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
|
|
|
|
|
|
<p className="mt-0.5 truncate text-sm text-foreground">{row.category_name || 'Uncategorized'}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
|
2026-05-28 20:14:00 -05:00
|
|
|
|
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', row.actual_amount != null ? 'text-amber-300' : 'text-foreground')}>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{fmt(threshold)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-05-10 00:52:23 -05:00
|
|
|
|
<div>
|
|
|
|
|
|
<p className="uppercase tracking-wide text-muted-foreground/60">Last Month</p>
|
2026-05-28 20:14:00 -05:00
|
|
|
|
<p className="tracker-number mt-0.5 text-sm font-medium text-muted-foreground/80">
|
2026-05-10 00:52:23 -05:00
|
|
|
|
{fmt(row.previous_month_paid)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<div>
|
|
|
|
|
|
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
|
2026-05-28 20:14:00 -05:00
|
|
|
|
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', remaining > 0 ? 'text-foreground' : 'text-emerald-300')}>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{fmt(remaining)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
<div className="rounded-md border border-border/50 bg-muted/20">
|
|
|
|
|
|
<PaymentProgress row={row} threshold={threshold} onOpen={() => setPaymentLedgerOpen(true)} compact />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
|
|
|
|
|
|
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
|
|
|
|
|
<span className="text-muted-foreground">Paid </span>
|
2026-05-28 20:14:00 -05:00
|
|
|
|
<span className="tracker-number font-semibold text-emerald-300">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
|
|
|
|
|
<span className="text-muted-foreground">Date </span>
|
2026-05-16 15:38:28 -05:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setPaymentLedgerOpen(true)}
|
2026-05-28 20:14:00 -05:00
|
|
|
|
className="tracker-number rounded font-medium text-foreground underline-offset-2 hover:underline"
|
2026-05-16 15:38:28 -05:00
|
|
|
|
>
|
|
|
|
|
|
{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
|
|
|
|
|
|
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
|
|
|
|
|
|
</button>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
2026-05-16 15:38:28 -05:00
|
|
|
|
{hasAutopaySuggestion && (
|
|
|
|
|
|
<AutopaySuggestionActions
|
|
|
|
|
|
row={row}
|
|
|
|
|
|
loading={suggestionLoading}
|
|
|
|
|
|
onConfirm={handleConfirmSuggestion}
|
|
|
|
|
|
onDismiss={handleDismissSuggestion}
|
|
|
|
|
|
compact
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
ref={amountRef}
|
|
|
|
|
|
type="number" min="0" step="0.01"
|
2026-05-16 15:38:28 -05:00
|
|
|
|
defaultValue={summary.remaining || threshold}
|
2026-05-28 20:14:00 -05:00
|
|
|
|
className="tracker-number h-8 w-24 text-right text-sm font-medium bg-background/70 border-border/60"
|
2026-05-04 13:14:32 -05:00
|
|
|
|
title="Payment amount"
|
|
|
|
|
|
aria-label={`${row.name} payment amount`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm" variant="default"
|
|
|
|
|
|
onClick={handleQuickPay}
|
|
|
|
|
|
className="h-8 px-3 text-xs font-semibold"
|
|
|
|
|
|
>
|
2026-05-16 15:38:28 -05:00
|
|
|
|
Add
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-16 20:26:09 -05:00
|
|
|
|
<LowerThisMonthButton
|
|
|
|
|
|
row={row}
|
|
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
|
|
|
|
|
refresh={refresh}
|
|
|
|
|
|
compact
|
|
|
|
|
|
/>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="rounded-md border border-border/50 bg-muted/25 px-2 py-1.5">
|
2026-05-09 13:03:36 -05:00
|
|
|
|
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{editPayment && (
|
|
|
|
|
|
<PaymentModal
|
|
|
|
|
|
payment={editPayment}
|
|
|
|
|
|
onClose={() => setEditPayment(null)}
|
|
|
|
|
|
onSave={refresh}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
{paymentLedgerOpen && (
|
|
|
|
|
|
<PaymentLedgerDialog
|
|
|
|
|
|
row={row}
|
2026-05-16 20:26:09 -05:00
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
2026-05-16 15:38:28 -05:00
|
|
|
|
threshold={threshold}
|
|
|
|
|
|
defaultPaymentDate={defaultPaymentDate}
|
|
|
|
|
|
onClose={() => setPaymentLedgerOpen(false)}
|
|
|
|
|
|
onSaved={refresh}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{showMbs && (
|
|
|
|
|
|
<MonthlyStateDialog
|
|
|
|
|
|
row={row}
|
|
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
|
|
|
|
|
open={showMbs}
|
|
|
|
|
|
onOpenChange={setShowMbs}
|
|
|
|
|
|
onSaved={refresh}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-05-16 10:34:32 -05:00
|
|
|
|
|
|
|
|
|
|
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
|
This removes the current payment record for this month and moves it into recovery.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
|
|
|
|
<AlertDialogAction
|
|
|
|
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
|
|
|
|
onClick={performTogglePaid}
|
|
|
|
|
|
>
|
|
|
|
|
|
Remove Payment
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
// ── Bucket ─────────────────────────────────────────────────────────────────
|
2026-05-29 21:16:13 -05:00
|
|
|
|
function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
|
2026-05-03 19:51:57 -05:00
|
|
|
|
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
|
|
|
|
|
|
const activeRows = rows.filter(r => !r.is_skipped);
|
|
|
|
|
|
const totalThreshold = activeRows.reduce((s, r) => s + (r.actual_amount ?? r.expected_amount ?? 0), 0);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const totalPaid = activeRows.reduce((s, r) => s + (r.total_paid || 0), 0);
|
|
|
|
|
|
const totalPaidTowardDue = activeRows.reduce((s, r) => {
|
|
|
|
|
|
const threshold = Number(r.actual_amount ?? r.expected_amount ?? 0) || 0;
|
|
|
|
|
|
const cappedPaid = Number(r.paid_toward_due);
|
|
|
|
|
|
return s + (Number.isFinite(cappedPaid) ? cappedPaid : Math.min(Number(r.total_paid) || 0, threshold));
|
|
|
|
|
|
}, 0);
|
2026-05-29 20:33:01 -05:00
|
|
|
|
const totalOverpaid = Math.max(totalPaid - totalPaidTowardDue, 0);
|
|
|
|
|
|
const totalRemaining = Math.max(totalThreshold - totalPaidTowardDue, 0);
|
|
|
|
|
|
const skippedCount = rows.length - activeRows.length;
|
|
|
|
|
|
const pct = totalThreshold > 0 ? Math.min((totalPaidTowardDue / totalThreshold) * 100, 100) : 0;
|
|
|
|
|
|
const allPaid = pct >= 100;
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-28 19:58:01 -05:00
|
|
|
|
<div className="rounded-xl border border-border/80 overflow-hidden bg-card/95 shadow-sm shadow-black/15">
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
{/* Bucket header */}
|
2026-05-28 19:58:01 -05:00
|
|
|
|
<div className="flex items-center justify-between px-5 py-3 bg-muted/35 border-b border-border/80">
|
2026-05-03 19:51:57 -05:00
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{skippedCount > 0 && (
|
|
|
|
|
|
<span className="text-[10px] text-muted-foreground/60">
|
|
|
|
|
|
({skippedCount} skipped)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="h-1.5 w-24 rounded-full bg-border overflow-hidden">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'h-full rounded-full transition-all duration-700',
|
|
|
|
|
|
allPaid ? 'bg-emerald-500' : 'bg-emerald-400/70',
|
|
|
|
|
|
)}
|
|
|
|
|
|
style={{ width: `${pct}%` }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="text-[11px] font-mono text-muted-foreground/70">
|
|
|
|
|
|
{Math.round(pct)}%
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-29 20:33:01 -05:00
|
|
|
|
<div className="flex items-center gap-3 text-xs font-mono text-muted-foreground">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
|
|
|
|
|
|
{fmt(totalPaidTowardDue)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-muted-foreground/50 mx-1">/</span>
|
|
|
|
|
|
{fmt(totalThreshold)}
|
|
|
|
|
|
{totalOverpaid > 0 && (
|
|
|
|
|
|
<span className="ml-1 text-emerald-500">+{fmt(totalOverpaid)}</span>
|
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</span>
|
2026-05-29 20:33:01 -05:00
|
|
|
|
{!allPaid && totalRemaining > 0 && (
|
|
|
|
|
|
<span className="text-[11px] text-muted-foreground/70">{fmt(totalRemaining)} left</span>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
)}
|
2026-05-29 20:33:01 -05:00
|
|
|
|
{allPaid && (
|
|
|
|
|
|
<span className="text-[11px] text-emerald-500">Done</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-10 01:35:41 -05:00
|
|
|
|
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
Array.from({ length: 3 }).map((_, i) => (
|
|
|
|
|
|
<div key={i} className="rounded-lg border border-border/60 bg-background/60 p-3 animate-pulse">
|
|
|
|
|
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
|
|
|
|
<div className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted" />
|
|
|
|
|
|
<div className="h-4 w-32 rounded-md bg-muted" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="h-5 w-20 rounded-md bg-muted" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground mt-2">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
|
|
|
|
|
|
<div className="h-3 w-20 rounded-md bg-muted mt-0.5" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
|
|
|
|
|
|
<div className="h-3 w-20 rounded-md bg-muted mt-0.5" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
2026-05-16 15:38:28 -05:00
|
|
|
|
) : rows.length === 0 ? (
|
|
|
|
|
|
<div className="rounded-lg border border-dashed border-border/70 bg-background/40 px-4 py-8 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
No bills match this bucket and filter set.
|
|
|
|
|
|
</div>
|
2026-05-10 01:35:41 -05:00
|
|
|
|
) : (
|
|
|
|
|
|
rows.map((r, i) => (
|
|
|
|
|
|
<MobileTrackerRow
|
|
|
|
|
|
key={r.id}
|
|
|
|
|
|
row={r}
|
|
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
|
|
|
|
|
refresh={refresh}
|
|
|
|
|
|
index={i}
|
|
|
|
|
|
onEditBill={onEditBill}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-10 01:35:41 -05:00
|
|
|
|
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
|
2026-05-09 13:03:36 -05:00
|
|
|
|
<div className="overflow-x-auto">
|
2026-05-29 21:16:13 -05:00
|
|
|
|
<Table className="min-w-[1120px]">
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<TableHeader>
|
2026-05-28 19:58:01 -05:00
|
|
|
|
<TableRow className="border-border/80 bg-background/30 hover:bg-background/30">
|
|
|
|
|
|
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Bill</TableHead>
|
2026-05-29 21:16:13 -05:00
|
|
|
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Due</TableHead>
|
2026-05-28 19:58:01 -05:00
|
|
|
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 text-right">Expected</TableHead>
|
2026-05-29 21:16:13 -05:00
|
|
|
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/80 text-right">Last Month</TableHead>
|
2026-05-28 19:58:01 -05:00
|
|
|
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 text-right">Paid</TableHead>
|
|
|
|
|
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Paid Date</TableHead>
|
|
|
|
|
|
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Status</TableHead>
|
2026-05-29 21:16:13 -05:00
|
|
|
|
<TableHead className="w-[10%] py-2.5" />
|
|
|
|
|
|
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 border-l border-border/80 pl-4">
|
2026-05-04 13:14:32 -05:00
|
|
|
|
Notes
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
2026-05-10 01:35:41 -05:00
|
|
|
|
{loading ? (
|
|
|
|
|
|
Array.from({ length: 5 }).map((_, i) => (
|
|
|
|
|
|
<TableRow key={i} className="border-border/50">
|
|
|
|
|
|
<TableCell className="w-[18%] py-3">
|
|
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
|
|
|
|
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
|
|
|
|
|
|
<div className="h-4 w-48 rounded-md bg-muted" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="w-[10%] py-3"><div className="h-3 w-20 rounded-md bg-muted" /></TableCell>
|
|
|
|
|
|
<TableCell className="w-[10%] py-3 text-right"><div className="h-3 w-20 ml-auto rounded-md bg-muted" /></TableCell>
|
|
|
|
|
|
<TableCell className="w-[10%] py-3 text-right"><div className="h-3 w-20 ml-auto rounded-md bg-muted" /></TableCell>
|
|
|
|
|
|
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 ml-auto rounded-md bg-muted" /></TableCell>
|
|
|
|
|
|
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 rounded-md bg-muted" /></TableCell>
|
|
|
|
|
|
<TableCell className="w-[9%] py-3"><div className="h-5 w-20 rounded-md bg-muted" /></TableCell>
|
|
|
|
|
|
<TableCell className="w-[10%] py-3 text-right">
|
|
|
|
|
|
<div className="flex items-center justify-end gap-1">
|
|
|
|
|
|
<div className="h-7 w-20 rounded-md bg-muted" />
|
|
|
|
|
|
<div className="h-7 w-7 rounded-md bg-muted" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
|
|
|
|
|
|
<div className="h-4 w-full rounded-md bg-muted" />
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
))
|
2026-05-16 15:38:28 -05:00
|
|
|
|
) : rows.length === 0 ? (
|
|
|
|
|
|
<TableRow className="border-border/50">
|
|
|
|
|
|
<TableCell colSpan={9} className="py-10 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
No bills match this bucket and filter set.
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
2026-05-10 01:35:41 -05:00
|
|
|
|
) : (
|
|
|
|
|
|
rows.map((r, i) => (
|
|
|
|
|
|
<Row
|
|
|
|
|
|
key={r.id}
|
|
|
|
|
|
row={r}
|
|
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
|
|
|
|
|
refresh={refresh}
|
|
|
|
|
|
index={i}
|
|
|
|
|
|
onEditBill={onEditBill}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
2026-05-09 13:03:36 -05:00
|
|
|
|
</div>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Main page ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
export default function TrackerPage() {
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const [searchParams] = useSearchParams();
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const [year, setYear] = useState(now.getFullYear());
|
|
|
|
|
|
const [month, setMonth] = useState(now.getMonth() + 1);
|
|
|
|
|
|
// Edit Bill modal: { bill, categories } when open, null when closed
|
|
|
|
|
|
const [editBillData, setEditBillData] = useState(null);
|
2026-05-04 20:12:57 -05:00
|
|
|
|
// Edit Starting Amounts modal: true when open, false when closed
|
|
|
|
|
|
const [editStartingOpen, setEditStartingOpen] = useState(false);
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
|
const [filters, setFilters] = useState({
|
|
|
|
|
|
category: FILTER_ALL,
|
|
|
|
|
|
cycle: FILTER_ALL,
|
|
|
|
|
|
autopay: false,
|
|
|
|
|
|
firstBucket: false,
|
|
|
|
|
|
fifteenthBucket: false,
|
|
|
|
|
|
unpaid: false,
|
|
|
|
|
|
overdue: false,
|
|
|
|
|
|
debt: false,
|
|
|
|
|
|
});
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-05-10 03:10:43 -05:00
|
|
|
|
// Use React Query for data fetching
|
|
|
|
|
|
const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const querySearch = searchParams.get('search') || '';
|
|
|
|
|
|
if (querySearch) setSearch(querySearch);
|
|
|
|
|
|
}, [searchParams]);
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
function navigate(delta) {
|
|
|
|
|
|
setMonth(m => {
|
|
|
|
|
|
const nm = m + delta;
|
|
|
|
|
|
if (nm > 12) { setYear(y => y + 1); return 1; }
|
|
|
|
|
|
if (nm < 1) { setYear(y => y - 1); return 12; }
|
|
|
|
|
|
return nm;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleOpenEditBill(row) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [bill, categories] = await Promise.all([
|
|
|
|
|
|
api.bill(row.id),
|
|
|
|
|
|
api.categories(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
setEditBillData({ bill, categories });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function goToday() {
|
|
|
|
|
|
const n = new Date();
|
|
|
|
|
|
setYear(n.getFullYear());
|
|
|
|
|
|
setMonth(n.getMonth() + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 03:10:43 -05:00
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
const rows = data?.rows || [];
|
|
|
|
|
|
const summary = data?.summary || {};
|
2026-05-16 15:38:28 -05:00
|
|
|
|
const toggleFilter = (key) => setFilters(prev => ({ ...prev, [key]: !prev[key] }));
|
|
|
|
|
|
const setFilterValue = (key, value) => setFilters(prev => ({ ...prev, [key]: value }));
|
|
|
|
|
|
const hasFilters = !!(
|
|
|
|
|
|
search.trim()
|
|
|
|
|
|
|| filters.category !== FILTER_ALL
|
|
|
|
|
|
|| filters.cycle !== FILTER_ALL
|
|
|
|
|
|
|| filters.autopay
|
|
|
|
|
|
|| filters.firstBucket
|
|
|
|
|
|
|| filters.fifteenthBucket
|
|
|
|
|
|
|| filters.unpaid
|
|
|
|
|
|
|| filters.overdue
|
|
|
|
|
|
|| filters.debt
|
|
|
|
|
|
);
|
|
|
|
|
|
const resetFilters = () => {
|
|
|
|
|
|
setSearch('');
|
|
|
|
|
|
setFilters({
|
|
|
|
|
|
category: FILTER_ALL,
|
|
|
|
|
|
cycle: FILTER_ALL,
|
|
|
|
|
|
autopay: false,
|
|
|
|
|
|
firstBucket: false,
|
|
|
|
|
|
fifteenthBucket: false,
|
|
|
|
|
|
unpaid: false,
|
|
|
|
|
|
overdue: false,
|
|
|
|
|
|
debt: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
const categoryOptions = useMemo(() => {
|
|
|
|
|
|
const map = new Map();
|
|
|
|
|
|
rows.forEach(row => {
|
|
|
|
|
|
if (row.category_id && row.category_name) map.set(String(row.category_id), row.category_name);
|
|
|
|
|
|
});
|
|
|
|
|
|
return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
|
}, [rows]);
|
|
|
|
|
|
const cycleOptions = useMemo(() => (
|
|
|
|
|
|
Array.from(new Set(rows.map(row => row.billing_cycle || 'monthly'))).sort()
|
|
|
|
|
|
), [rows]);
|
|
|
|
|
|
const filteredRows = useMemo(() => {
|
|
|
|
|
|
const q = search.trim().toLowerCase();
|
|
|
|
|
|
return rows.filter(row => {
|
|
|
|
|
|
const effectiveStatus = rowEffectiveStatus(row);
|
|
|
|
|
|
if (filters.category !== FILTER_ALL && String(row.category_id ?? '') !== filters.category) return false;
|
|
|
|
|
|
if (filters.cycle !== FILTER_ALL && String(row.billing_cycle || 'monthly') !== filters.cycle) return false;
|
|
|
|
|
|
if (filters.autopay && !row.autopay_enabled) return false;
|
|
|
|
|
|
if (filters.debt && !rowIsDebt(row)) return false;
|
|
|
|
|
|
if (filters.unpaid && (row.is_skipped || rowIsPaid(row))) return false;
|
|
|
|
|
|
if (filters.overdue && !(effectiveStatus === 'late' || effectiveStatus === 'missed')) return false;
|
|
|
|
|
|
if (filters.firstBucket && !filters.fifteenthBucket && row.bucket !== '1st') return false;
|
|
|
|
|
|
if (filters.fifteenthBucket && !filters.firstBucket && row.bucket !== '15th') return false;
|
|
|
|
|
|
|
|
|
|
|
|
if (!q) return true;
|
|
|
|
|
|
const haystack = [
|
|
|
|
|
|
row.name,
|
|
|
|
|
|
row.category_name,
|
|
|
|
|
|
row.notes,
|
|
|
|
|
|
row.monthly_notes,
|
|
|
|
|
|
row.billing_cycle,
|
|
|
|
|
|
row.bucket,
|
|
|
|
|
|
row.status,
|
|
|
|
|
|
amountSearchText(row.expected_amount, row.actual_amount, row.total_paid, row.balance, row.current_balance, row.minimum_payment),
|
|
|
|
|
|
].filter(Boolean).join(' ').toLowerCase();
|
|
|
|
|
|
return haystack.includes(q);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [filters, rows, search]);
|
|
|
|
|
|
const first = filteredRows.filter(r => r.bucket === '1st');
|
|
|
|
|
|
const second = filteredRows.filter(r => r.bucket === '15th');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Header ── */}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
2026-05-03 19:51:57 -05:00
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
|
|
|
|
|
|
Monthly Overview
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight">
|
|
|
|
|
|
{MONTHS[month - 1]}
|
|
|
|
|
|
<span className="text-muted-foreground font-normal ml-2 text-xl">{year}</span>
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
|
|
|
|
{rows.length} {rows.length === 1 ? 'bill' : 'bills'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-1 bg-muted/50 border border-border rounded-lg p-1">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="icon" variant="ghost"
|
|
|
|
|
|
onClick={() => navigate(-1)}
|
|
|
|
|
|
className="h-7 w-7 hover:bg-white/5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronLeft className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm" variant="ghost"
|
|
|
|
|
|
onClick={goToday}
|
|
|
|
|
|
className="h-7 px-3 text-xs font-medium hover:bg-white/5"
|
|
|
|
|
|
>
|
|
|
|
|
|
Today
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="icon" variant="ghost"
|
|
|
|
|
|
onClick={() => navigate(1)}
|
|
|
|
|
|
className="h-7 w-7 hover:bg-white/5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-28 19:58:01 -05:00
|
|
|
|
<div className="rounded-xl border border-border/80 bg-card/95 p-4 shadow-sm shadow-black/15 space-y-3">
|
2026-05-16 15:38:28 -05:00
|
|
|
|
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_220px_180px_auto] lg:items-center">
|
|
|
|
|
|
<label className="relative">
|
|
|
|
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={search}
|
|
|
|
|
|
onChange={e => setSearch(e.target.value)}
|
|
|
|
|
|
placeholder="Search this month by bill, category, notes, or amount"
|
|
|
|
|
|
className="h-10 pl-9"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
|
|
|
|
|
|
<SelectTrigger className="h-10">
|
|
|
|
|
|
<SelectValue placeholder="Category" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value={FILTER_ALL}>All categories</SelectItem>
|
|
|
|
|
|
{categoryOptions.map(category => (
|
|
|
|
|
|
<SelectItem key={category.id} value={category.id}>{category.name}</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
|
|
|
|
|
|
<SelectTrigger className="h-10 capitalize">
|
|
|
|
|
|
<SelectValue placeholder="Billing cycle" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value={FILTER_ALL}>All cycles</SelectItem>
|
|
|
|
|
|
{cycleOptions.map(cycle => (
|
|
|
|
|
|
<SelectItem key={cycle} value={cycle}>{cycle.replace(/_/g, ' ')}</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
disabled={!hasFilters}
|
|
|
|
|
|
onClick={resetFilters}
|
|
|
|
|
|
className="h-10 justify-center gap-2 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
|
Clear
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<FilterChip active={filters.unpaid} onClick={() => toggleFilter('unpaid')}>Unpaid</FilterChip>
|
|
|
|
|
|
<FilterChip active={filters.overdue} onClick={() => toggleFilter('overdue')}>Overdue</FilterChip>
|
|
|
|
|
|
<FilterChip active={filters.autopay} onClick={() => toggleFilter('autopay')}>Autopay</FilterChip>
|
|
|
|
|
|
<FilterChip active={filters.firstBucket} onClick={() => toggleFilter('firstBucket')}>1st bucket</FilterChip>
|
|
|
|
|
|
<FilterChip active={filters.fifteenthBucket} onClick={() => toggleFilter('fifteenthBucket')}>15th bucket</FilterChip>
|
|
|
|
|
|
<FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>Debt</FilterChip>
|
|
|
|
|
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
|
|
|
|
|
{filteredRows.length} of {rows.length} shown
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
2026-05-10 01:35:41 -05:00
|
|
|
|
{loading ? (
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true">
|
|
|
|
|
|
<Skeleton variant="card" className="h-32" />
|
|
|
|
|
|
<Skeleton variant="card" className="h-32" />
|
|
|
|
|
|
<Skeleton variant="card" className="h-32" />
|
|
|
|
|
|
<Skeleton variant="card" className="h-32" />
|
|
|
|
|
|
<Skeleton variant="card" className="h-32" />
|
|
|
|
|
|
{summary.trend && <Skeleton variant="card" className="h-32" />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3 lg:flex">
|
|
|
|
|
|
<SummaryCard
|
|
|
|
|
|
type="starting"
|
|
|
|
|
|
value={summary.total_starting}
|
|
|
|
|
|
hint={!summary.has_starting_amounts ? 'Set monthly starting cash' : ''}
|
|
|
|
|
|
onEdit={() => setEditStartingOpen(true)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<SummaryCard type="paid" value={summary.total_paid} />
|
|
|
|
|
|
<SummaryCard type="overdue" value={summary.overdue} />
|
|
|
|
|
|
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
|
|
|
|
|
|
{summary.trend && <TrendCard trend={summary.trend} />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-05-28 02:34:24 -05:00
|
|
|
|
{/* ── Fetch error state ── */}
|
|
|
|
|
|
{isError && (
|
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-destructive/20 bg-destructive/5">
|
|
|
|
|
|
<div className="h-10 w-10 rounded-full bg-destructive/10 flex items-center justify-center mb-3">
|
|
|
|
|
|
<AlertCircle className="h-5 w-5 text-destructive" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-sm font-medium text-foreground">Failed to load tracker data</p>
|
|
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">{error?.message || 'An unexpected error occurred.'}</p>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={() => refetch()}
|
|
|
|
|
|
className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw className="h-3 w-3" />
|
|
|
|
|
|
Try again
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
{/* ── Empty state ── */}
|
2026-05-28 02:34:24 -05:00
|
|
|
|
{!isError && rows.length === 0 && data !== null && (
|
2026-05-03 19:51:57 -05:00
|
|
|
|
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-border bg-muted/20">
|
|
|
|
|
|
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center mb-3">
|
|
|
|
|
|
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground">No bills this month</p>
|
|
|
|
|
|
<a href="/bills" className="mt-1.5 text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground transition-colors">
|
|
|
|
|
|
Add a bill
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Buckets — year and month passed so Row can open the correct monthly state dialog ── */}
|
2026-05-28 02:34:24 -05:00
|
|
|
|
{!isError && loading && (
|
2026-05-10 01:35:41 -05:00
|
|
|
|
<div className="space-y-5" aria-busy="true">
|
|
|
|
|
|
<div className="rounded-xl border border-border overflow-hidden bg-card p-4">
|
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
|
<div className="h-4 w-32 rounded-md bg-muted" />
|
|
|
|
|
|
<div className="h-4 w-16 rounded-md bg-muted" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
|
|
|
|
<div key={i} className="flex items-center gap-3 animate-pulse">
|
|
|
|
|
|
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
|
|
|
|
|
|
<div className="h-4 w-64 rounded-md bg-muted" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-xl border border-border overflow-hidden bg-card p-4">
|
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
|
<div className="h-4 w-32 rounded-md bg-muted" />
|
|
|
|
|
|
<div className="h-4 w-16 rounded-md bg-muted" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
|
|
|
|
<div key={i} className="flex items-center gap-3 animate-pulse">
|
|
|
|
|
|
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
|
|
|
|
|
|
<div className="h-4 w-64 rounded-md bg-muted" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-29 20:33:01 -05:00
|
|
|
|
{!isError && (first.length > 0 || second.length > 0) && (
|
2026-05-29 21:16:13 -05:00
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
|
{first.length > 0 && <Bucket label="1st – 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
|
|
|
|
|
|
{second.length > 0 && <Bucket label="15th – 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
|
2026-05-29 20:33:01 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
|
|
{/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
|
|
|
|
|
|
{editBillData && (
|
|
|
|
|
|
<BillModal
|
2026-05-16 15:38:28 -05:00
|
|
|
|
key={editBillData.bill?.id ? `edit-${editBillData.bill.id}` : `new-${editBillData.initialBill?.name || 'blank'}`}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
bill={editBillData.bill}
|
2026-05-16 15:38:28 -05:00
|
|
|
|
initialBill={editBillData.initialBill}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
categories={editBillData.categories}
|
|
|
|
|
|
onClose={() => setEditBillData(null)}
|
2026-05-10 03:10:43 -05:00
|
|
|
|
onSave={() => { setEditBillData(null); refetch(); }}
|
2026-05-16 15:38:28 -05:00
|
|
|
|
onDuplicate={bill => setEditBillData({
|
|
|
|
|
|
bill: null,
|
|
|
|
|
|
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
|
|
|
|
|
|
categories: editBillData.categories,
|
|
|
|
|
|
})}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-04 20:12:57 -05:00
|
|
|
|
{/* Edit Starting Amounts modal */}
|
|
|
|
|
|
<StartingAmountsEditDialog
|
|
|
|
|
|
open={editStartingOpen}
|
|
|
|
|
|
onClose={() => setEditStartingOpen(false)}
|
|
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
2026-05-10 03:10:43 -05:00
|
|
|
|
onSave={() => { setEditStartingOpen(false); refetch(); }}
|
2026-05-04 20:12:57 -05:00
|
|
|
|
/>
|
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|