1555 lines
60 KiB
JavaScript
1555 lines
60 KiB
JavaScript
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2 } from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
import { api } from '@/api.js';
|
||
import { useTracker } from '@/hooks/useQueries';
|
||
import BillModal from '@/components/BillModal';
|
||
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Skeleton } from '@/components/ui/Skeleton';
|
||
import {
|
||
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
|
||
} from '@/components/ui/table';
|
||
import {
|
||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||
} from '@/components/ui/dialog';
|
||
import {
|
||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||
} from '@/components/ui/select';
|
||
import { Label } from '@/components/ui/label';
|
||
|
||
const MONTHS = [
|
||
'January','February','March','April','May','June',
|
||
'July','August','September','October','November','December',
|
||
];
|
||
|
||
// 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 = {
|
||
paid: 'bg-emerald-500/[0.04]',
|
||
autodraft: 'bg-sky-500/[0.04]',
|
||
upcoming: '',
|
||
due_soon: 'bg-amber-400/[0.07]',
|
||
late: 'bg-orange-400/[0.08]',
|
||
missed: 'bg-red-400/[0.08]',
|
||
};
|
||
|
||
const STATUS_META = {
|
||
paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30' },
|
||
upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' },
|
||
due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30' },
|
||
late: { label: 'Late', cls: 'bg-orange-400/15 text-orange-500 border border-orange-400/30' },
|
||
missed: { label: 'Missed', cls: 'bg-red-400/15 text-red-500 border border-red-400/30' },
|
||
autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30' },
|
||
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
|
||
};
|
||
|
||
// ── Summary cards ──────────────────────────────────────────────────────────
|
||
const CARD_DEFS = {
|
||
starting: {
|
||
label: 'Starting',
|
||
icon: TrendingUp,
|
||
bar: 'from-slate-400 to-slate-300',
|
||
glow: '',
|
||
valueClass: 'text-foreground',
|
||
activateWhen: () => true,
|
||
},
|
||
paid: {
|
||
label: 'Total Paid',
|
||
icon: CheckCircle2,
|
||
bar: 'from-emerald-500 to-emerald-300',
|
||
glow: 'shadow-[0_4px_20px_rgba(16,185,129,0.15)]',
|
||
borderActive: 'border-emerald-400/40',
|
||
valueClass: 'text-emerald-500',
|
||
activateWhen: (v) => v > 0,
|
||
},
|
||
remaining: {
|
||
label: 'Remaining',
|
||
icon: Clock,
|
||
bar: 'from-blue-400 to-indigo-300',
|
||
glow: '',
|
||
valueClass: 'text-foreground',
|
||
activateWhen: () => true,
|
||
},
|
||
overdue: {
|
||
label: 'Overdue',
|
||
icon: AlertCircle,
|
||
bar: 'from-rose-500 to-orange-400',
|
||
glow: 'shadow-[0_4px_20px_rgba(239,68,68,0.12)]',
|
||
borderActive: 'border-red-400/40',
|
||
valueClass: 'text-red-500',
|
||
activateWhen: (v) => v > 0,
|
||
},
|
||
};
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
function SummaryCard({ type, value, onEdit, hint }) {
|
||
const def = CARD_DEFS[type];
|
||
const isActive = def.activateWhen(value || 0);
|
||
const Icon = def.icon;
|
||
|
||
return (
|
||
<div className={cn(
|
||
'flex-1 min-w-0 relative overflow-hidden rounded-xl border border-border',
|
||
'bg-card px-5 py-4 transition-all duration-300',
|
||
isActive && def.glow,
|
||
isActive && def.borderActive,
|
||
)}>
|
||
<div className={cn(
|
||
'absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r',
|
||
def.bar,
|
||
!isActive && (type === 'paid' || type === 'overdue') && 'opacity-30',
|
||
)} />
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Icon className={cn('h-4 w-4', isActive ? def.valueClass : 'text-muted-foreground')} />
|
||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||
{def.label}
|
||
</p>
|
||
{type === 'starting' && onEdit && (
|
||
<button
|
||
onClick={onEdit}
|
||
className="ml-auto h-4 w-4 text-muted-foreground hover:text-foreground transition-colors"
|
||
title="Edit monthly starting amounts"
|
||
aria-label="Edit monthly starting amounts"
|
||
>
|
||
<Settings2 className="h-4 w-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<p className={cn(
|
||
'text-[1.75rem] font-bold tracking-tight font-mono leading-none',
|
||
isActive ? def.valueClass : 'text-foreground',
|
||
)}>
|
||
{fmt(value)}
|
||
</p>
|
||
{hint && <p className="mt-2 text-[11px] text-muted-foreground">{hint}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TrendCard({ trend }) {
|
||
if (!trend) return null;
|
||
|
||
return (
|
||
<div className="flex-1 min-w-0 relative overflow-hidden rounded-xl border border-border bg-card px-5 py-4 transition-all duration-300">
|
||
<div className="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-purple-500 to-indigo-400" />
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<TrendingUp className="h-4 w-4 text-foreground" />
|
||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||
3-Month Trend
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center justify-center h-10">
|
||
<TrendIndicator trend={trend} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Status badge ───────────────────────────────────────────────────────────
|
||
const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }) {
|
||
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
|
||
|
||
const isSkipped = status === 'skipped';
|
||
const canClick = clickable && !isSkipped && !loading;
|
||
|
||
return (
|
||
<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',
|
||
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}
|
||
</>
|
||
) : (
|
||
meta.label
|
||
)}
|
||
</button>
|
||
);
|
||
});
|
||
|
||
// ── 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>
|
||
);
|
||
}
|
||
|
||
// ── 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.
|
||
function NotesCell({ row, refresh }) {
|
||
// Monthly notes - the per-month notes stored in monthly_bill_state
|
||
const savedNote = row.monthly_notes || '';
|
||
const [value, setValue] = useState(savedNote);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
async function handleBlur() {
|
||
const trimmed = value.trim();
|
||
if (trimmed === savedNote) return;
|
||
|
||
// 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);
|
||
return;
|
||
}
|
||
|
||
setSaving(true);
|
||
try {
|
||
await api.saveBillMonthlyState(row.id, {
|
||
year,
|
||
month,
|
||
notes: trimmed || null,
|
||
is_skipped: row.is_skipped,
|
||
actual_amount: row.actual_amount,
|
||
});
|
||
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(); }}
|
||
placeholder='Add monthly notes…'
|
||
disabled={saving}
|
||
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',
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ── Monthly state dialog ───────────────────────────────────────────────────
|
||
// Edits actual_amount, monthly notes, and is_skipped for a specific bill+month.
|
||
// Changes are isolated to the selected month — other months are not affected.
|
||
function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
|
||
const [actualAmount, setActualAmount] = useState('');
|
||
const [notes, setNotes] = useState('');
|
||
const [isSkipped, setIsSkipped] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
// Populate from current row state when dialog opens
|
||
useEffect(() => {
|
||
if (open) {
|
||
setActualAmount(row.actual_amount != null ? String(row.actual_amount) : '');
|
||
setNotes(row.monthly_notes || '');
|
||
setIsSkipped(!!row.is_skipped);
|
||
}
|
||
}, [open, row]);
|
||
|
||
async function handleSave(e) {
|
||
e.preventDefault();
|
||
const amt = actualAmount.trim() ? parseFloat(actualAmount) : null;
|
||
if (amt !== null && (isNaN(amt) || amt < 0)) {
|
||
toast.error('Amount must be a positive number or empty');
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
await api.saveBillMonthlyState(row.id, {
|
||
year,
|
||
month,
|
||
actual_amount: amt,
|
||
notes: notes.trim() || null,
|
||
is_skipped: isSkipped,
|
||
});
|
||
toast.success(`${MONTHS[month - 1]} state saved`);
|
||
onSaved();
|
||
onOpenChange(false);
|
||
} catch (err) {
|
||
toast.error(err.message);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-base font-semibold tracking-tight">
|
||
{row.name}
|
||
<span className="text-muted-foreground font-normal ml-2">
|
||
{MONTHS[month - 1]} {year}
|
||
</span>
|
||
</DialogTitle>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
Monthly overrides — changes only affect {MONTHS[month - 1]}
|
||
</p>
|
||
</DialogHeader>
|
||
|
||
<form id="mbs-form" onSubmit={handleSave} className="space-y-4 py-1">
|
||
{/* Actual amount this month */}
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="mbs-amount" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||
Actual Amount ($)
|
||
</Label>
|
||
<Input
|
||
id="mbs-amount"
|
||
type="number" min="0" step="0.01"
|
||
placeholder={String(row.expected_amount)}
|
||
value={actualAmount}
|
||
onChange={e => setActualAmount(e.target.value)}
|
||
className="font-mono bg-background/50 border-border/60"
|
||
/>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
Leave blank to use the template default ({fmt(row.expected_amount)}).
|
||
</p>
|
||
</div>
|
||
|
||
{/* Monthly notes */}
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="mbs-notes" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||
Notes (this month only)
|
||
</Label>
|
||
<Input
|
||
id="mbs-notes"
|
||
value={notes}
|
||
onChange={e => setNotes(e.target.value)}
|
||
placeholder="e.g. higher than usual, double-billed…"
|
||
className="bg-background/50 border-border/60"
|
||
/>
|
||
</div>
|
||
|
||
{/* Skip this month */}
|
||
<label className="flex items-start gap-3 cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={isSkipped}
|
||
onChange={e => setIsSkipped(e.target.checked)}
|
||
className="mt-0.5 h-4 w-4 rounded border-border accent-primary"
|
||
/>
|
||
<div>
|
||
<p className="text-sm font-medium leading-tight">Skip this month</p>
|
||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||
Excludes this bill from {MONTHS[month - 1]} totals. Other months are unchanged.
|
||
</p>
|
||
</div>
|
||
</label>
|
||
</form>
|
||
|
||
<DialogFooter className="mt-2">
|
||
<Button type="button" variant="ghost" disabled={saving} onClick={() => onOpenChange(false)} className="text-xs">
|
||
Cancel
|
||
</Button>
|
||
<Button type="submit" form="mbs-form" disabled={saving} className="text-xs">
|
||
{saving ? 'Saving…' : 'Save'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
|
||
const [loading, setLoading] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [firstAmount, setFirstAmount] = useState('0');
|
||
const [fifteenthAmount, setFifteenthAmount] = useState('0');
|
||
const [otherAmount, setOtherAmount] = useState('0');
|
||
const [preview, setPreview] = useState(null);
|
||
|
||
const monthName = `${MONTHS[month - 1]} ${year}`;
|
||
const localFirst = Number(firstAmount) || 0;
|
||
const localFifteenth = Number(fifteenthAmount) || 0;
|
||
const localOther = Number(otherAmount) || 0;
|
||
const totalStarting = localFirst + localFifteenth + localOther;
|
||
const paidSoFar = Number(preview?.paid_total || 0);
|
||
const firstRemaining = localFirst - Number(preview?.paid_from_first || 0);
|
||
const fifteenthRemaining = localFifteenth - Number(preview?.paid_from_fifteenth || 0);
|
||
const totalRemaining = totalStarting - paidSoFar;
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
async function loadStartingAmounts() {
|
||
if (!open) return;
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const result = await api.getMonthlyStartingAmounts(year, month);
|
||
if (!alive) return;
|
||
setPreview(result);
|
||
setFirstAmount(String(result.first_amount ?? 0));
|
||
setFifteenthAmount(String(result.fifteenth_amount ?? 0));
|
||
setOtherAmount(String(result.other_amount ?? 0));
|
||
} catch (err) {
|
||
if (!alive) return;
|
||
setError(err.message || 'Monthly starting amounts could not be loaded.');
|
||
} finally {
|
||
if (alive) setLoading(false);
|
||
}
|
||
}
|
||
loadStartingAmounts();
|
||
return () => { alive = false; };
|
||
}, [open, year, month]);
|
||
|
||
async function handleSave(e) {
|
||
e.preventDefault();
|
||
const first = Number(firstAmount);
|
||
const fifteenth = Number(fifteenthAmount);
|
||
const other = Number(otherAmount);
|
||
if (![first, fifteenth, other].every(value => Number.isFinite(value) && value >= 0)) {
|
||
setError('Starting amounts must be non-negative numbers.');
|
||
return;
|
||
}
|
||
|
||
setSaving(true);
|
||
setError('');
|
||
try {
|
||
await api.updateMonthlyStartingAmounts({
|
||
year,
|
||
month,
|
||
first_amount: first,
|
||
fifteenth_amount: fifteenth,
|
||
other_amount: other,
|
||
});
|
||
toast.success('Monthly starting amounts saved.');
|
||
onSave();
|
||
} catch (err) {
|
||
setError(err.message || 'Monthly starting amounts could not be saved.');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }}>
|
||
<DialogContent className="max-h-[92vh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-lg font-semibold tracking-tight">Monthly Starting Amounts</DialogTitle>
|
||
<p className="text-sm text-muted-foreground">{monthName}</p>
|
||
</DialogHeader>
|
||
|
||
<form id="starting-amounts-form" onSubmit={handleSave} className="space-y-5">
|
||
{error && (
|
||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
<label className="space-y-1.5">
|
||
<Label htmlFor="starting-first" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||
1st
|
||
</Label>
|
||
<Input
|
||
id="starting-first"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={firstAmount}
|
||
disabled={loading || saving}
|
||
onChange={e => setFirstAmount(e.target.value)}
|
||
className="font-mono bg-background/50 border-border/60"
|
||
/>
|
||
</label>
|
||
<label className="space-y-1.5">
|
||
<Label htmlFor="starting-fifteenth" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||
15th
|
||
</Label>
|
||
<Input
|
||
id="starting-fifteenth"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={fifteenthAmount}
|
||
disabled={loading || saving}
|
||
onChange={e => setFifteenthAmount(e.target.value)}
|
||
className="font-mono bg-background/50 border-border/60"
|
||
/>
|
||
</label>
|
||
<label className="space-y-1.5">
|
||
<Label htmlFor="starting-other" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||
Other
|
||
</Label>
|
||
<Input
|
||
id="starting-other"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={otherAmount}
|
||
disabled={loading || saving}
|
||
onChange={e => setOtherAmount(e.target.value)}
|
||
className="font-mono bg-background/50 border-border/60"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-border/60 bg-muted/35 p-4">
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
<div>
|
||
<p className="text-xs font-medium text-muted-foreground">Total starting</p>
|
||
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalStarting)}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-medium text-muted-foreground">Paid so far</p>
|
||
<p className="mt-1 font-mono text-lg font-bold text-emerald-500">{fmt(paidSoFar)}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-medium text-muted-foreground">Total remaining</p>
|
||
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalRemaining)}</p>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 grid gap-2 border-t border-border/60 pt-3 text-sm sm:grid-cols-3">
|
||
<div className="flex justify-between gap-3 sm:block">
|
||
<span className="text-muted-foreground">1st remaining</span>
|
||
<span className="font-mono font-semibold sm:block">{fmt(firstRemaining)}</span>
|
||
</div>
|
||
<div className="flex justify-between gap-3 sm:block">
|
||
<span className="text-muted-foreground">15th remaining</span>
|
||
<span className="font-mono font-semibold sm:block">{fmt(fifteenthRemaining)}</span>
|
||
</div>
|
||
<div className="flex justify-between gap-3 sm:block">
|
||
<span className="text-muted-foreground">Other</span>
|
||
<span className="font-mono font-semibold sm:block">{fmt(localOther)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
|
||
<DialogFooter className="mt-2">
|
||
<Button type="button" variant="ghost" disabled={saving} onClick={onClose} className="text-xs">
|
||
Cancel
|
||
</Button>
|
||
<Button type="submit" form="starting-amounts-form" disabled={loading || saving} className="text-xs">
|
||
{saving ? 'Saving...' : 'Save'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ── Payment modal ──────────────────────────────────────────────────────────
|
||
function PaymentModal({ payment, onClose, onSave }) {
|
||
const [amount, setAmount] = useState(String(payment.amount));
|
||
const [date, setDate] = useState(payment.paid_date);
|
||
// Use METHOD_NONE sentinel — empty string value crashes Radix Select
|
||
const [method, setMethod] = useState(payment.method || METHOD_NONE);
|
||
const [notes, setNotes] = useState(payment.notes || '');
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function handleSave(e) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
try {
|
||
await api.updatePayment(payment.id, {
|
||
amount: parseFloat(amount),
|
||
paid_date: date,
|
||
method: method === METHOD_NONE ? null : method,
|
||
notes: notes || null,
|
||
});
|
||
toast.success('Payment saved');
|
||
onSave(); onClose();
|
||
} catch (err) {
|
||
toast.error(err.message);
|
||
} finally { setBusy(false); }
|
||
}
|
||
|
||
async function handleDelete() {
|
||
setBusy(true);
|
||
try {
|
||
await api.deletePayment(payment.id);
|
||
toast.success('Payment removed. Bill is now marked as unpaid.');
|
||
onSave(); onClose();
|
||
} catch (err) {
|
||
toast.error(err.message);
|
||
} finally { setBusy(false); }
|
||
}
|
||
|
||
return (
|
||
<Dialog open onOpenChange={v => { if (!v) onClose(); }}>
|
||
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-base font-semibold tracking-tight">Edit Payment</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
<form id="payment-modal-form" onSubmit={handleSave} className="space-y-4">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="pm-amount" className="text-xs uppercase tracking-wider text-muted-foreground">Amount ($) *</Label>
|
||
<Input id="pm-amount" type="number" min="0" step="0.01" required
|
||
value={amount} onChange={e => setAmount(e.target.value)}
|
||
className="font-mono bg-background/50 border-border/60" />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="pm-date" className="text-xs uppercase tracking-wider text-muted-foreground">Paid Date *</Label>
|
||
<Input id="pm-date" type="date" required
|
||
value={date} onChange={e => setDate(e.target.value)}
|
||
className="font-mono bg-background/50 border-border/60" />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="pm-method" className="text-xs uppercase tracking-wider text-muted-foreground">Method</Label>
|
||
<Select value={method} onValueChange={setMethod}>
|
||
<SelectTrigger id="pm-method" className="bg-background/50 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="pm-notes" className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
|
||
<Input id="pm-notes" value={notes} onChange={e => setNotes(e.target.value)}
|
||
className="bg-background/50 border-border/60" />
|
||
</div>
|
||
</form>
|
||
|
||
<DialogFooter className="flex-row gap-2 sm:justify-between mt-2">
|
||
<Button
|
||
type="button" variant="destructive" disabled={busy} onClick={handleDelete}
|
||
className="text-xs"
|
||
title="Removes this payment record. The bill itself is NOT deleted."
|
||
>
|
||
Remove Payment
|
||
</Button>
|
||
<div className="flex gap-2">
|
||
<Button type="button" variant="ghost" disabled={busy} onClick={onClose} className="text-xs">Cancel</Button>
|
||
<Button type="submit" form="payment-modal-form" disabled={busy} className="text-xs">Save</Button>
|
||
</div>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ── Table row ──────────────────────────────────────────────────────────────
|
||
function Row({ row, year, month, refresh, index, onEditBill }) {
|
||
const amountRef = useRef(null);
|
||
const [editPayment, setEditPayment] = useState(null);
|
||
const [showMbs, setShowMbs] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
// Effective amount threshold for this bill this month:
|
||
// actual_amount (if set by monthly override) takes priority over the template expected_amount.
|
||
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
||
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
||
|
||
// Paid when total payments >= effective threshold
|
||
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
||
const isPaid = row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold;
|
||
|
||
const isSkipped = !!row.is_skipped;
|
||
|
||
// 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 });
|
||
toast.success('Marked as paid');
|
||
refresh();
|
||
} catch (err) {
|
||
toast.error(err.message);
|
||
}
|
||
}
|
||
|
||
async function handleTogglePaid() {
|
||
setLoading?.(true);
|
||
try {
|
||
const result = await api.togglePaid(row.id, {
|
||
amount: isPaid ? undefined : threshold,
|
||
year: year,
|
||
month: month,
|
||
});
|
||
toast.success(isPaid ? 'Payment removed' : 'Payment recorded');
|
||
refresh?.();
|
||
} catch (err) {
|
||
toast.error(err.message || 'Failed to toggle payment status');
|
||
} finally {
|
||
setLoading?.(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<TableRow
|
||
className={cn(
|
||
'group border-border/50 transition-colors duration-150',
|
||
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">
|
||
{row.autopay_enabled && (
|
||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" title="Autopay" />
|
||
)}
|
||
<div>
|
||
<div className="flex items-center gap-1">
|
||
{row.website ? (
|
||
<a
|
||
href={row.website}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className={cn(
|
||
'font-medium text-sm leading-tight transition-colors',
|
||
'hover:underline decoration-muted-foreground/50 underline-offset-2',
|
||
isSkipped && 'line-through',
|
||
)}
|
||
>
|
||
{row.name}
|
||
</a>
|
||
) : (
|
||
<span className={cn('font-medium text-sm leading-tight', isSkipped && 'line-through')}>
|
||
{row.name}
|
||
</span>
|
||
)}
|
||
<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>
|
||
{row.category_name && (
|
||
<p className="text-[11px] text-muted-foreground mt-0.5">{row.category_name}</p>
|
||
)}
|
||
{/* 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 */}
|
||
<TableCell className="w-[10%] py-3 text-sm font-mono text-muted-foreground">
|
||
{fmtDate(row.due_date)}
|
||
</TableCell>
|
||
|
||
{/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
|
||
<TableCell className="w-[10%] py-3 text-right font-mono text-sm">
|
||
{row.actual_amount != null ? (
|
||
<span
|
||
className="text-amber-500"
|
||
title={`Monthly override. Template default: ${fmt(row.expected_amount)}`}
|
||
>
|
||
{fmt(row.actual_amount)}
|
||
</span>
|
||
) : (
|
||
<span className="text-muted-foreground">{fmt(row.expected_amount)}</span>
|
||
)}
|
||
</TableCell>
|
||
|
||
{/* Previous month paid */}
|
||
<TableCell className="w-[10%] py-3 text-right font-mono text-sm text-muted-foreground/70">
|
||
{row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'}
|
||
</TableCell>
|
||
|
||
{/* Amount paid — mismatch now compares against threshold */}
|
||
<TableCell className="w-[10%] py-3 text-right">
|
||
<EditableCell
|
||
row={row}
|
||
field="amount"
|
||
threshold={threshold}
|
||
defaultPaymentDate={defaultPaymentDate}
|
||
refresh={refresh}
|
||
/>
|
||
</TableCell>
|
||
|
||
{/* Paid date */}
|
||
<TableCell className="w-[10%] py-3">
|
||
<EditableCell
|
||
row={row}
|
||
field="date"
|
||
threshold={threshold}
|
||
defaultPaymentDate={defaultPaymentDate}
|
||
refresh={refresh}
|
||
/>
|
||
</TableCell>
|
||
|
||
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
|
||
<TableCell className="w-[9%] py-3">
|
||
<StatusBadge
|
||
status={effectiveStatus}
|
||
clickable
|
||
onClick={() => {
|
||
if (effectiveStatus === 'skipped') return;
|
||
handleTogglePaid();
|
||
}}
|
||
loading={loading}
|
||
/>
|
||
</TableCell>
|
||
|
||
{/* Actions */}
|
||
<TableCell className="w-[10%] py-3 text-right">
|
||
<div className="flex items-center justify-end gap-1">
|
||
{/* Quick pay — hidden for skipped bills */}
|
||
{!isPaid && !isSkipped && (
|
||
<div className="flex items-center gap-1">
|
||
<Input
|
||
ref={amountRef}
|
||
type="number" min="0" step="0.01"
|
||
defaultValue={threshold}
|
||
className="h-7 w-20 text-right font-mono text-sm bg-background/50 border-border/50"
|
||
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"
|
||
>
|
||
Pay
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</TableCell>
|
||
|
||
{/* Notes cell (monthly state notes) */}
|
||
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
|
||
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
|
||
</TableCell>
|
||
</TableRow>
|
||
|
||
{editPayment && (
|
||
<PaymentModal
|
||
payment={editPayment}
|
||
onClose={() => setEditPayment(null)}
|
||
onSave={refresh}
|
||
/>
|
||
)}
|
||
|
||
{showMbs && (
|
||
<MonthlyStateDialog
|
||
row={row}
|
||
year={year}
|
||
month={month}
|
||
open={showMbs}
|
||
onOpenChange={setShowMbs}
|
||
onSaved={refresh}
|
||
/>
|
||
)}
|
||
|
||
{/* Payment toggle confirmation dialog */}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||
const amountRef = useRef(null);
|
||
const [editPayment, setEditPayment] = useState(null);
|
||
const [showMbs, setShowMbs] = useState(false);
|
||
|
||
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
||
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
||
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
||
const isPaid = row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold;
|
||
const isSkipped = !!row.is_skipped;
|
||
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);
|
||
|
||
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 });
|
||
toast.success('Marked as paid');
|
||
refresh();
|
||
} catch (err) {
|
||
toast.error(err.message);
|
||
}
|
||
}
|
||
|
||
async function handleTogglePaid() {
|
||
try {
|
||
await api.togglePaid(row.id, {
|
||
amount: isPaid ? undefined : threshold,
|
||
year: year,
|
||
month: month,
|
||
});
|
||
toast.success(isPaid ? 'Payment removed' : 'Payment recorded');
|
||
refresh();
|
||
} catch (err) {
|
||
toast.error(err.message || 'Failed to toggle payment status');
|
||
}
|
||
}
|
||
|
||
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">
|
||
{row.autopay_enabled && (
|
||
<span
|
||
className="inline-flex shrink-0 rounded bg-sky-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-sky-500"
|
||
title="Autopay"
|
||
>
|
||
AP
|
||
</span>
|
||
)}
|
||
{row.website ? (
|
||
<a
|
||
href={row.website}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className={cn(
|
||
'min-w-0 truncate text-sm font-semibold leading-tight text-foreground',
|
||
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
||
isSkipped && 'line-through',
|
||
)}
|
||
>
|
||
{row.name}
|
||
</a>
|
||
) : (
|
||
<span className={cn('min-w-0 truncate text-sm font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
|
||
{row.name}
|
||
</span>
|
||
)}
|
||
<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>
|
||
{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>
|
||
<StatusBadge status={effectiveStatus} clickable={!isSkipped} onClick={handleTogglePaid} />
|
||
</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>
|
||
<p className="mt-0.5 font-mono text-sm text-foreground">{fmtDate(row.due_date)}</p>
|
||
</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>
|
||
<p className={cn('mt-0.5 font-mono text-sm', row.actual_amount != null ? 'text-amber-500' : 'text-foreground')}>
|
||
{fmt(threshold)}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className="uppercase tracking-wide text-muted-foreground/60">Last Month</p>
|
||
<p className="mt-0.5 font-mono text-sm text-muted-foreground/70">
|
||
{fmt(row.previous_month_paid)}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
|
||
<p className={cn('mt-0.5 font-mono text-sm', remaining > 0 ? 'text-foreground' : 'text-emerald-500')}>
|
||
{fmt(remaining)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
<span className="font-mono text-emerald-500">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
|
||
</div>
|
||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
||
<span className="text-muted-foreground">Date </span>
|
||
<span className="font-mono text-foreground">{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||
{!isPaid && !isSkipped && (
|
||
<div className="flex items-center gap-1.5">
|
||
<Input
|
||
ref={amountRef}
|
||
type="number" min="0" step="0.01"
|
||
defaultValue={threshold}
|
||
className="h-8 w-24 text-right font-mono text-sm bg-background/70 border-border/60"
|
||
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"
|
||
>
|
||
Pay
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-md border border-border/50 bg-muted/25 px-2 py-1.5">
|
||
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
|
||
</div>
|
||
</div>
|
||
|
||
{editPayment && (
|
||
<PaymentModal
|
||
payment={editPayment}
|
||
onClose={() => setEditPayment(null)}
|
||
onSave={refresh}
|
||
/>
|
||
)}
|
||
|
||
{showMbs && (
|
||
<MonthlyStateDialog
|
||
row={row}
|
||
year={year}
|
||
month={month}
|
||
open={showMbs}
|
||
onOpenChange={setShowMbs}
|
||
onSaved={refresh}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── Bucket ─────────────────────────────────────────────────────────────────
|
||
function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
|
||
// 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);
|
||
const totalPaid = activeRows.reduce((s, r) => s + (r.total_paid || 0), 0);
|
||
const skippedCount = rows.length - activeRows.length;
|
||
const pct = totalThreshold > 0 ? Math.min((totalPaid / totalThreshold) * 100, 100) : 0;
|
||
const allPaid = pct >= 100;
|
||
|
||
return (
|
||
<div className="rounded-xl border border-border overflow-hidden bg-card">
|
||
|
||
{/* Bucket header */}
|
||
<div className="flex items-center justify-between px-5 py-3 bg-muted/30 border-b border-border">
|
||
<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>
|
||
<span className="text-xs font-mono text-muted-foreground">
|
||
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
|
||
{fmt(totalPaid)}
|
||
</span>
|
||
<span className="text-muted-foreground/50 mx-1">/</span>
|
||
{fmt(totalThreshold)}
|
||
</span>
|
||
</div>
|
||
|
||
<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>
|
||
))
|
||
) : (
|
||
rows.map((r, i) => (
|
||
<MobileTrackerRow
|
||
key={r.id}
|
||
row={r}
|
||
year={year}
|
||
month={month}
|
||
refresh={refresh}
|
||
index={i}
|
||
onEditBill={onEditBill}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
|
||
<div className="overflow-x-auto">
|
||
<Table className="min-w-[1120px]">
|
||
<TableHeader>
|
||
<TableRow className="border-border hover:bg-transparent">
|
||
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Bill</TableHead>
|
||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Due</TableHead>
|
||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Expected</TableHead>
|
||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right text-muted-foreground/70">Last Month</TableHead>
|
||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Paid</TableHead>
|
||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Paid Date</TableHead>
|
||
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Status</TableHead>
|
||
<TableHead className="w-[10%] py-2.5" />
|
||
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-l border-border pl-4">
|
||
Notes
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{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>
|
||
))
|
||
) : (
|
||
rows.map((r, i) => (
|
||
<Row
|
||
key={r.id}
|
||
row={r}
|
||
year={year}
|
||
month={month}
|
||
refresh={refresh}
|
||
index={i}
|
||
onEditBill={onEditBill}
|
||
/>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Main page ──────────────────────────────────────────────────────────────
|
||
export default function TrackerPage() {
|
||
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);
|
||
// Edit Starting Amounts modal: true when open, false when closed
|
||
const [editStartingOpen, setEditStartingOpen] = useState(false);
|
||
|
||
// Use React Query for data fetching
|
||
const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month);
|
||
|
||
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);
|
||
}
|
||
|
||
// Handle errors from React Query (use ref to prevent duplicate toasts)
|
||
const errorShownRef = useRef(false);
|
||
useEffect(() => {
|
||
if (isError && !errorShownRef.current) {
|
||
toast.error(error?.message || 'Failed to load tracker data');
|
||
errorShownRef.current = true;
|
||
}
|
||
if (!isError) errorShownRef.current = false;
|
||
}, [isError, error]);
|
||
|
||
const rows = data?.rows || [];
|
||
const summary = data?.summary || {};
|
||
const first = rows.filter(r => r.bucket === '1st');
|
||
const second = rows.filter(r => r.bucket === '15th');
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
|
||
{/* ── Header ── */}
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||
<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>
|
||
|
||
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
||
{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="remaining" value={summary.remaining} />
|
||
<SummaryCard type="overdue" value={summary.overdue} />
|
||
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
|
||
{summary.trend && <TrendCard trend={summary.trend} />}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Empty state ── */}
|
||
{rows.length === 0 && data !== null && (
|
||
<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 ── */}
|
||
{loading && (
|
||
<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>
|
||
)}
|
||
{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} />}
|
||
|
||
{/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
|
||
{editBillData && (
|
||
<BillModal
|
||
bill={editBillData.bill}
|
||
categories={editBillData.categories}
|
||
onClose={() => setEditBillData(null)}
|
||
onSave={() => { setEditBillData(null); refetch(); }}
|
||
/>
|
||
)}
|
||
|
||
{/* Edit Starting Amounts modal */}
|
||
<StartingAmountsEditDialog
|
||
open={editStartingOpen}
|
||
onClose={() => setEditStartingOpen(false)}
|
||
year={year}
|
||
month={month}
|
||
onSave={() => { setEditStartingOpen(false); refetch(); }}
|
||
/>
|
||
|
||
</div>
|
||
);
|
||
}
|