import React, { useState, useRef, useTransition } from 'react'; import { ArrowDown, ArrowUp, CheckCircle2, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; 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'; export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted }) { 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(); } 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; } } 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 ( <> {/* Bill name + category + monthly notes (if set) */}
{row.website ? ( {row.name} ) : ( {row.name} )} {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 ( {hasFailed ? '⚠' : null}AP {needsVerify && } {stats && stats.total > 0 ? (

{hasFailed ? '⚠' : '✓'} {stats.total - stats.failures}/{stats.total} on time (12mo)

) : (

Autopay enabled

)} {hasFailed && stats.last_failure_date && (

Last failed: {stats.last_failure_date}

)} {hasFailed && stats.last_failure_notes && (

{stats.last_failure_notes}

)} {needsVerify ? (

{daysSinceVerify === null ? 'Never verified — confirm it\'s still active' : `Verified ${daysSinceVerify}d ago — check soon`}

) : (

Verified {daysSinceVerify}d ago

)}
); })()} {(row.has_merchant_rule || row.has_linked_transactions) && ( L Linked to bank transactions )} {row.is_subscription && ( S Subscription )}
{row.category_name && (

{row.category_name}

)} {/* Monthly notes shown inline under the bill name */} {row.monthly_notes && (

{row.monthly_notes}

)}
{/* Due */} {editingDue ? ( 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)" /> ) : ( )} {/* Expected / Actual — shows actual_amount in amber when it overrides the template */} {editingExpected ? ( 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 ? ( ) : (
{isDrifted && ( Changed )} {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 ( ); })()} {row.amount_suggestion?.suggestion != null && Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && ( )}
)}
{/* Previous month paid */} {row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'} {/* Amount paid — mismatch now compares against threshold */} setPaymentLedgerOpen(true)} onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined} /> {/* Paid date */} {/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
{ if (effectiveStatus === 'skipped') return; handleTogglePaid(); }} loading={loading} /> {row.pending_cleared && ( Pending )}
{/* Actions */}
{showUpdateNudge ? (
Update default?
) : ( <> {hasAutopaySuggestion && ( )} {/* Quick pay — hidden for skipped/paid bills */} {!isPaid && !isSkipped && !hasAutopaySuggestion && (
)} )}
{/* Notes cell (monthly state notes) */}
{editPayment && ( setEditPayment(null)} onSave={refresh} /> )} {paymentLedgerOpen && ( setPaymentLedgerOpen(false)} onSaved={refresh} /> )} {showMbs && ( )} Mark this bill unpaid? This removes the current payment record for this month and moves it into recovery. Cancel {loading ? 'Removing...' : 'Remove Payment'} ); }