291 lines
11 KiB
React
291 lines
11 KiB
React
|
|
import React, { useMemo, useRef, useState } from 'react';
|
||
|
|
import { Pencil, Settings2 } from 'lucide-react';
|
||
|
|
import { toast } from 'sonner';
|
||
|
|
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import { StatusBadge } from './StatusBadge';
|
||
|
|
import { api } from '@/api.js';
|
||
|
|
|
||
|
|
const MONTHS = [
|
||
|
|
'January','February','March','April','May','June',
|
||
|
|
'July','August','September','October','November','December',
|
||
|
|
];
|
||
|
|
|
||
|
|
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]',
|
||
|
|
};
|
||
|
|
|
||
|
|
function paymentDateForTrackerMonth(year, month, dueDay) {
|
||
|
|
const now = new Date();
|
||
|
|
if (year === now.getFullYear() && month === now.getMonth() + 1) {
|
||
|
|
return fmtDate(new Date().toISOString().slice(0, 10));
|
||
|
|
}
|
||
|
|
|
||
|
|
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')}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) {
|
||
|
|
const [editing, setEditing] = useState(false);
|
||
|
|
const [value, setValue] = useState('');
|
||
|
|
const inputRef = useRef(null);
|
||
|
|
|
||
|
|
const displayVal = useMemo(() => {
|
||
|
|
if (field === 'amount') {
|
||
|
|
return row.total_paid > 0 ? fmt(row.total_paid) : '—';
|
||
|
|
}
|
||
|
|
return row.last_paid_date ? fmtDate(row.last_paid_date) : '—';
|
||
|
|
}, [field, row]);
|
||
|
|
|
||
|
|
const isEmpty = useMemo(() => {
|
||
|
|
if (field === 'amount') return row.total_paid <= 0;
|
||
|
|
return !row.last_paid_date;
|
||
|
|
}, [field, row]);
|
||
|
|
|
||
|
|
const mismatch = useMemo(() => {
|
||
|
|
if (field === 'amount') {
|
||
|
|
return row.total_paid > 0 && row.total_paid !== threshold;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}, [field, row, 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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export const MobileTrackerRow = React.memo(function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||
|
|
const amountRef = useRef(null);
|
||
|
|
|
||
|
|
const threshold = useMemo(() => row.actual_amount != null ? row.actual_amount : row.expected_amount, [row]);
|
||
|
|
const defaultPaymentDate = useMemo(() => paymentDateForTrackerMonth(year, month, row.due_day), [year, month, row.due_day]);
|
||
|
|
const isPaidByThreshold = useMemo(() => row.total_paid > 0 && row.total_paid >= threshold, [row, threshold]);
|
||
|
|
const isPaid = useMemo(() => row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold, [row.status, isPaidByThreshold]);
|
||
|
|
const isSkipped = useMemo(() => !!row.is_skipped, [row.is_skipped]);
|
||
|
|
|
||
|
|
const effectiveStatus = useMemo(() => {
|
||
|
|
if (isSkipped) return 'skipped';
|
||
|
|
if (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') return 'paid';
|
||
|
|
return row.status;
|
||
|
|
}, [isSkipped, isPaidByThreshold, row.status]);
|
||
|
|
|
||
|
|
const rowBg = useMemo(() => isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''), [isSkipped, effectiveStatus]);
|
||
|
|
const remaining = useMemo(() => Math.max((threshold || 0) - (row.total_paid || 0), 0), [threshold, row.total_paid]);
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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>
|
||
|
|
)}
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => onEditBill?.(row)}
|
||
|
|
className={cn(
|
||
|
|
'min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground',
|
||
|
|
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
||
|
|
isSkipped && 'line-through',
|
||
|
|
)}
|
||
|
|
title="Edit bill"
|
||
|
|
>
|
||
|
|
{row.name}
|
||
|
|
</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} />
|
||
|
|
</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">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>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{row.payments && row.payments.length > 0 && (
|
||
|
|
<Button
|
||
|
|
size="sm" variant="ghost"
|
||
|
|
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||
|
|
title="Edit payment"
|
||
|
|
onClick={() => setEditPayment(row.payments[0])}
|
||
|
|
>
|
||
|
|
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||
|
|
Payment
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<Button
|
||
|
|
size="sm" variant="ghost"
|
||
|
|
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||
|
|
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
||
|
|
onClick={() => setShowMbs(true)}
|
||
|
|
>
|
||
|
|
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
|
||
|
|
Month
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
MobileTrackerRow.displayName = 'MobileTrackerRow';
|