import React, { useState, useRef, useTransition } from 'react'; import { ArrowDown, ArrowUp, GripVertical, Pencil, 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 }) { 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 && ( AP Autopay enabled )} {(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 ? ( ) : (
{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'} ); }