2026-05-31 15:06:10 -05:00
|
|
|
|
import React, { useState, useRef, useTransition } from 'react';
|
2026-06-07 14:49:39 -05:00
|
|
|
|
import { ArrowDown, ArrowUp, CheckCircle2, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react';
|
2026-06-06 23:29:34 -05:00
|
|
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
2026-05-31 15:06:10 -05:00
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
import { api } from '@/api.js';
|
|
|
|
|
|
import { cn, fmt, fmtDate } from '@/lib/utils';
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
|
import {
|
|
|
|
|
|
TableRow, TableCell,
|
|
|
|
|
|
} from '@/components/ui/table';
|
|
|
|
|
|
import {
|
|
|
|
|
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
|
|
|
|
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
|
|
|
|
|
} from '@/components/ui/alert-dialog';
|
|
|
|
|
|
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
|
|
|
|
|
import PaymentModal from '@/components/tracker/PaymentModal';
|
|
|
|
|
|
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
|
|
|
|
|
import { StatusBadge } from '@/components/tracker/StatusBadge';
|
|
|
|
|
|
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
|
|
|
|
|
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
|
|
|
|
|
|
import { NotesCell } from '@/components/tracker/NotesCell';
|
|
|
|
|
|
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
|
|
|
|
|
|
|
2026-06-07 14:49:39 -05:00
|
|
|
|
export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted }) {
|
2026-05-31 15:06:10 -05:00
|
|
|
|
const amountRef = useRef(null);
|
|
|
|
|
|
const [editPayment, setEditPayment] = useState(null);
|
|
|
|
|
|
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
|
|
|
|
|
const [showMbs, setShowMbs] = useState(false);
|
|
|
|
|
|
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
|
|
|
|
|
const [optimisticActual, setOptimisticActual] = useState(undefined);
|
|
|
|
|
|
const [showUpdateNudge, setShowUpdateNudge] = useState(false);
|
|
|
|
|
|
const [nudgeAmount, setNudgeAmount] = useState(null);
|
|
|
|
|
|
const [, startTransition] = useTransition();
|
|
|
|
|
|
|
|
|
|
|
|
const [editingExpected, setEditingExpected] = useState(false);
|
|
|
|
|
|
const [expectedDraft, setExpectedDraft] = useState('');
|
|
|
|
|
|
const [editingDue, setEditingDue] = useState(false);
|
|
|
|
|
|
const [dueDraft, setDueDraft] = useState('');
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
|
|
|
|
|
|
|
|
|
|
|
const isSkipped = !!row.is_skipped;
|
|
|
|
|
|
const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
|
|
|
|
|
|
|
|
|
|
|
|
// Paid when total payments >= effective threshold
|
|
|
|
|
|
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
|
|
|
|
|
const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
|
|
|
|
|
|
const summary = paymentSummary(row, threshold);
|
|
|
|
|
|
|
|
|
|
|
|
// 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('Payment added');
|
|
|
|
|
|
refresh();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function performTogglePaid() {
|
|
|
|
|
|
setLoading?.(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await api.togglePaid(row.id, {
|
|
|
|
|
|
amount: isPaid ? undefined : threshold,
|
|
|
|
|
|
year: year,
|
|
|
|
|
|
month: month,
|
|
|
|
|
|
});
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
refresh?.();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Failed to toggle payment status');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading?.(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleTogglePaid() {
|
|
|
|
|
|
if (isPaid) {
|
|
|
|
|
|
setConfirmUnpay(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
performTogglePaid();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 22:55:27 -05:00
|
|
|
|
function handleRowKeyDown(e) {
|
|
|
|
|
|
// Only act on the row element itself, not on interactive children
|
|
|
|
|
|
if (e.target !== e.currentTarget) return;
|
|
|
|
|
|
|
|
|
|
|
|
switch (e.key) {
|
|
|
|
|
|
case 'ArrowDown':
|
|
|
|
|
|
case 'j': {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const all = [...document.querySelectorAll('[data-tracker-row]')];
|
|
|
|
|
|
const next = all[all.indexOf(e.currentTarget) + 1];
|
|
|
|
|
|
next?.focus();
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'ArrowUp':
|
|
|
|
|
|
case 'k': {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const all = [...document.querySelectorAll('[data-tracker-row]')];
|
|
|
|
|
|
const prev = all[all.indexOf(e.currentTarget) - 1];
|
|
|
|
|
|
prev?.focus();
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'Enter': {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
onEditBill?.(row);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'p':
|
|
|
|
|
|
case 'P': {
|
|
|
|
|
|
if (e.ctrlKey || e.metaKey) break; // don't intercept Ctrl+P (print)
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
if (!isSkipped) handleTogglePaid();
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'Escape': {
|
|
|
|
|
|
e.currentTarget.blur();
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 15:06:10 -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 {
|
|
|
|
|
|
await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: amount });
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<TableRow
|
2026-06-03 22:55:27 -05:00
|
|
|
|
data-tracker-row
|
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
|
aria-rowindex={index + 1}
|
|
|
|
|
|
aria-label={`${row.name}, ${effectiveStatus}, ${row.due_day ? `due ${row.due_day}` : ''}`}
|
|
|
|
|
|
onKeyDown={handleRowKeyDown}
|
2026-05-31 15:06:10 -05:00
|
|
|
|
draggable={dragProps?.draggable}
|
|
|
|
|
|
onDragStart={dragProps?.onDragStart}
|
|
|
|
|
|
onDragEnter={dragProps?.onDragEnter}
|
|
|
|
|
|
onDragOver={dragProps?.onDragOver}
|
|
|
|
|
|
onDragEnd={dragProps?.onDragEnd}
|
|
|
|
|
|
onDrop={dragProps?.onDrop}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'group border-border/65 transition-colors duration-150',
|
2026-06-03 22:55:27 -05:00
|
|
|
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset',
|
2026-05-31 15:06:10 -05:00
|
|
|
|
isSkipped ? 'opacity-40' : 'hover:bg-accent/50',
|
|
|
|
|
|
rowBg,
|
|
|
|
|
|
dragProps?.isDragging && 'opacity-45',
|
|
|
|
|
|
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
|
|
|
|
|
)}
|
|
|
|
|
|
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 className="flex shrink-0 items-center gap-0.5">
|
|
|
|
|
|
<GripVertical
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'h-4 w-4 text-muted-foreground/55',
|
|
|
|
|
|
moveControls?.enabled && 'cursor-grab group-active:cursor-grabbing',
|
|
|
|
|
|
!moveControls?.enabled && 'opacity-30',
|
|
|
|
|
|
)}
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="hidden flex-col sm:flex">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={moveControls?.onMoveUp}
|
|
|
|
|
|
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
|
|
|
|
|
|
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
|
|
|
|
|
title="Move bill up"
|
|
|
|
|
|
aria-label={`Move ${row.name} up`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ArrowUp className="h-3 w-3" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={moveControls?.onMoveDown}
|
|
|
|
|
|
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
|
|
|
|
|
|
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
|
|
|
|
|
title="Move bill down"
|
|
|
|
|
|
aria-label={`Move ${row.name} down`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ArrowDown className="h-3 w-3" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
{row.website ? (
|
|
|
|
|
|
<a
|
|
|
|
|
|
href={row.website}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'text-[15px] font-semibold leading-tight text-foreground transition-colors',
|
|
|
|
|
|
'hover:underline decoration-muted-foreground/50 underline-offset-2',
|
|
|
|
|
|
isSkipped && 'line-through',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{row.name}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className={cn('text-[15px] font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
|
|
|
|
|
|
{row.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-06-06 23:29:34 -05:00
|
|
|
|
<TooltipProvider delayDuration={300}>
|
2026-06-07 14:49:39 -05:00
|
|
|
|
{row.autopay_enabled && (() => {
|
|
|
|
|
|
const stats = row.autopay_stats;
|
|
|
|
|
|
const hasFailed = stats && stats.failures > 0;
|
|
|
|
|
|
const verifiedAt = row.autopay_verified_at ? new Date(row.autopay_verified_at) : null;
|
|
|
|
|
|
const daysSinceVerify = verifiedAt
|
|
|
|
|
|
? Math.floor((Date.now() - verifiedAt) / 86400000)
|
|
|
|
|
|
: null;
|
|
|
|
|
|
const needsVerify = daysSinceVerify === null || daysSinceVerify > 90;
|
|
|
|
|
|
const badgeCls = hasFailed
|
|
|
|
|
|
? 'border-amber-500/40 bg-amber-500/15 text-amber-600 dark:text-amber-300'
|
|
|
|
|
|
: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-300';
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<span className={cn('inline-flex shrink-0 items-center gap-0.5 rounded border px-1.5 py-0.5 text-[10px] font-bold leading-none cursor-default', badgeCls)}>
|
|
|
|
|
|
{hasFailed ? '⚠' : null}AP
|
|
|
|
|
|
{needsVerify && <Clock className="h-2.5 w-2.5 ml-0.5 opacity-70" />}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent className="max-w-[220px] space-y-1">
|
|
|
|
|
|
{stats && stats.total > 0 ? (
|
|
|
|
|
|
<p className={cn('font-semibold', hasFailed ? 'text-amber-300' : 'text-emerald-300')}>
|
|
|
|
|
|
{hasFailed ? '⚠' : '✓'} {stats.total - stats.failures}/{stats.total} on time (12mo)
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="font-semibold">Autopay enabled</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{hasFailed && stats.last_failure_date && (
|
|
|
|
|
|
<p className="text-xs opacity-80">Last failed: {stats.last_failure_date}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{hasFailed && stats.last_failure_notes && (
|
|
|
|
|
|
<p className="text-xs opacity-70 italic">{stats.last_failure_notes}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{needsVerify ? (
|
|
|
|
|
|
<p className="text-xs opacity-70">
|
|
|
|
|
|
{daysSinceVerify === null ? 'Never verified — confirm it\'s still active' : `Verified ${daysSinceVerify}d ago — check soon`}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="text-xs opacity-70">Verified {daysSinceVerify}d ago</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
2026-06-06 23:29:34 -05:00
|
|
|
|
{(row.has_merchant_rule || row.has_linked_transactions) && (
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<span className="inline-flex shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-emerald-600 dark:text-emerald-400 cursor-default">
|
|
|
|
|
|
L
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Linked to bank transactions</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{row.is_subscription && (
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<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 cursor-default">
|
|
|
|
|
|
S
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Subscription</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TooltipProvider>
|
2026-05-31 15:06:10 -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>
|
|
|
|
|
|
{row.category_name && (
|
|
|
|
|
|
<p className="mt-0.5 text-xs text-muted-foreground/85">{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="tracker-number w-[10%] py-3 text-[13px] font-medium text-foreground/75">
|
|
|
|
|
|
{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); }
|
|
|
|
|
|
}}
|
|
|
|
|
|
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"
|
|
|
|
|
|
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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
|
|
|
|
|
|
<TableCell className="tracker-number w-[10%] py-3 text-right text-[13px] font-semibold">
|
|
|
|
|
|
{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); }
|
|
|
|
|
|
}}
|
|
|
|
|
|
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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : effectiveActual != null ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => { setExpectedDraft(String(effectiveActual)); setEditingExpected(true); }}
|
|
|
|
|
|
className="rounded px-1 py-0.5 text-amber-300 transition-colors hover:bg-accent"
|
|
|
|
|
|
title={`Monthly override — click to edit. Template default: ${fmt(row.expected_amount)}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{fmt(effectiveActual)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex flex-col items-end gap-0.5">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => { setExpectedDraft(String(row.expected_amount)); setEditingExpected(true); }}
|
|
|
|
|
|
className="rounded px-1 py-0.5 text-foreground/85 transition-colors hover:bg-accent hover:text-foreground"
|
|
|
|
|
|
title="Click to edit expected amount"
|
|
|
|
|
|
>
|
|
|
|
|
|
{fmt(row.expected_amount)}
|
|
|
|
|
|
</button>
|
2026-06-07 14:49:39 -05:00
|
|
|
|
{isDrifted && (
|
|
|
|
|
|
<span className="inline-flex items-center gap-0.5 text-[10px] font-semibold text-amber-500">
|
|
|
|
|
|
<TrendingUp className="h-2.5 w-2.5" />Changed
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{row.sparkline && row.sparkline.length >= 2 && (() => {
|
|
|
|
|
|
const vals = row.sparkline;
|
|
|
|
|
|
const min = Math.min(...vals);
|
|
|
|
|
|
const max = Math.max(...vals);
|
|
|
|
|
|
const range = max - min || 1;
|
|
|
|
|
|
const W = 44, H = 16;
|
|
|
|
|
|
const pts = vals.map((v, i) => {
|
|
|
|
|
|
const x = (i / (vals.length - 1)) * W;
|
|
|
|
|
|
const y = H - ((v - min) / range) * H;
|
|
|
|
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
|
|
|
|
}).join(' ');
|
|
|
|
|
|
return (
|
|
|
|
|
|
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="opacity-50">
|
|
|
|
|
|
<polyline points={pts} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
2026-05-31 15:06:10 -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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Previous month paid */}
|
|
|
|
|
|
<TableCell className="tracker-number w-[10%] py-3 text-right text-[13px] font-medium text-muted-foreground/80">
|
|
|
|
|
|
{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">
|
|
|
|
|
|
<PaymentProgress
|
|
|
|
|
|
row={row}
|
|
|
|
|
|
threshold={threshold}
|
|
|
|
|
|
onOpen={() => setPaymentLedgerOpen(true)}
|
|
|
|
|
|
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Paid date */}
|
|
|
|
|
|
<TableCell className="w-[10%] py-3 text-[13px] text-foreground/75">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setPaymentLedgerOpen(true)}
|
|
|
|
|
|
className="tracker-number rounded-md px-1.5 py-0.5 font-medium transition-colors hover:bg-accent hover:text-foreground"
|
|
|
|
|
|
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>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
|
|
|
|
|
|
<TableCell className="w-[9%] py-3">
|
2026-06-03 21:09:26 -05:00
|
|
|
|
<div className="flex flex-col items-start gap-1">
|
|
|
|
|
|
<StatusBadge
|
|
|
|
|
|
status={effectiveStatus}
|
|
|
|
|
|
clickable
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (effectiveStatus === 'skipped') return;
|
|
|
|
|
|
handleTogglePaid();
|
|
|
|
|
|
}}
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{row.pending_cleared && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="inline-flex items-center rounded-full border border-amber-400/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400"
|
|
|
|
|
|
title="Paid in tracker but may not have cleared your bank account yet"
|
|
|
|
|
|
>
|
|
|
|
|
|
Pending
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-31 15:06:10 -05:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
|
|
|
|
|
<TableCell className="w-[10%] py-3 text-right">
|
|
|
|
|
|
<div className="flex items-center justify-end gap-1">
|
|
|
|
|
|
{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>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm" variant="ghost"
|
|
|
|
|
|
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"
|
|
|
|
|
|
>
|
|
|
|
|
|
{fmt(nudgeAmount)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowUpdateNudge(false)}
|
|
|
|
|
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
|
|
|
|
|
title="Dismiss"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{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}
|
|
|
|
|
|
className="tracker-number h-7 w-20 text-right text-sm font-medium 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"
|
|
|
|
|
|
>
|
|
|
|
|
|
Add
|
|
|
|
|
|
</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}
|
2026-06-07 14:49:39 -05:00
|
|
|
|
autopayEnabled={!!row.autopay_enabled}
|
2026-05-31 15:06:10 -05:00
|
|
|
|
onClose={() => setEditPayment(null)}
|
|
|
|
|
|
onSave={refresh}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{paymentLedgerOpen && (
|
|
|
|
|
|
<PaymentLedgerDialog
|
|
|
|
|
|
row={row}
|
|
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
|
|
|
|
|
threshold={threshold}
|
|
|
|
|
|
defaultPaymentDate={defaultPaymentDate}
|
|
|
|
|
|
onClose={() => setPaymentLedgerOpen(false)}
|
|
|
|
|
|
onSaved={refresh}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{showMbs && (
|
|
|
|
|
|
<MonthlyStateDialog
|
|
|
|
|
|
row={row}
|
|
|
|
|
|
year={year}
|
|
|
|
|
|
month={month}
|
|
|
|
|
|
open={showMbs}
|
|
|
|
|
|
onOpenChange={setShowMbs}
|
|
|
|
|
|
onSaved={refresh}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|