import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { Banknote, CalendarDays, ChevronLeft, ChevronRight, CircleDollarSign, PiggyBank, RefreshCw, Target, TrendingDown, Trophy, WalletCards, } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; function currentMonth() { const now = new Date(); return { year: now.getFullYear(), month: now.getMonth() + 1 }; } function shiftMonth(year, month, delta) { const next = new Date(year, month - 1 + delta, 1); return { year: next.getFullYear(), month: next.getMonth() + 1 }; } function displayStatus(status) { if (status === 'due_soon') return 'Due'; if (status === 'late') return 'Late'; return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Due'; } function statusTone(status) { if (status === 'paid' || status === 'autodraft') return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300'; if (status === 'skipped') return 'border-border bg-muted/80 text-muted-foreground'; if (status === 'late') return 'border-orange-400/60 bg-orange-500/25 text-orange-800 shadow-sm shadow-orange-950/10 dark:text-orange-100'; if (status === 'missed') return 'border-rose-400/60 bg-rose-500/30 text-rose-800 shadow-sm shadow-rose-950/10 dark:text-rose-100'; return 'border-primary/30 bg-primary/15 text-primary'; } function LegendItem({ className, label }) { return ( {label} ); } function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }) { return (

{label}

{fmt(value)}

{hint &&

{hint}

}
); } function MoneyMap({ summaryData, loading }) { if (loading) { return ( {Array.from({ length: 4 }).map((_, index) => (
))} ); } const starting = summaryData?.starting_amounts || {}; const summary = summaryData?.summary || {}; const bt = summaryData?.bank_tracking; const bankMode = bt?.enabled === true; const available = bankMode ? Number(bt.effective_balance || 0) : Number(starting.combined_amount || 0); const assigned = Number(summary.expense_total || 0); const paid = Number(summary.paid_total || 0); const remaining = Number(summary.result || 0); const extraIncome = Number(starting.other_amount || 0); return (
Monthly Money Map {bankMode ? `Live bank balance · ${bt.account_name}` : 'Available money, extra income, assigned bills, and what remains.'}
{!bankMode && ( )}
{bankMode ? ( <> = 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'} /> ) : ( <> 0 ? 'text-teal-600 dark:text-teal-300' : ''} /> = 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'} /> )}
{!bankMode && (
1st available {fmt(starting.first_amount)}
15th available {fmt(starting.fifteenth_amount)}
Monthly income {fmt(summaryData?.income?.amount)}
)} {bankMode && (
= 0 ? 'border-emerald-500/25 bg-emerald-500/5' : 'border-destructive/25 bg-destructive/5', )}>

Projected Month-End Balance

{fmt(bt.balance || 0)} bank {Number(bt.pending_payments || 0) > 0 && ` − ${fmt(bt.pending_payments)} pending`} {` − ${fmt(bt.unpaid_this_month || 0)} remaining bills`}

= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive', )}> {Number(bt.remaining || 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bt.remaining || 0)))}

)} {bankMode && bt.last_updated && (

Balance last updated: {new Date(bt.last_updated).toLocaleString()}

)}
); } function SummaryProgress({ summary }) { const percent = Number(summary?.paid_percent || 0); return (
Total Expenses Paid
Monthly progress across active, unskipped bills.

{fmt(summary?.paid_total)} / {fmt(summary?.expected_total)}

{fmt(summary?.remaining_total)} remaining

{percent}%

paid

{summary?.bill_count || 0} active bills {summary?.paid_count || 0} paid {!!summary?.skipped_count && {summary.skipped_count} skipped} {!!summary?.missed_count && {summary.missed_count} late or missed}
); } function DayIndicators({ day, moneyMarker }) { const summary = day.status_summary; const hasPaid = summary.paid_count > 0; const hasDue = summary.due_count > summary.paid_count + summary.skipped_count + summary.missed_count; const hasSkipped = summary.skipped_count > 0; const hasMissed = summary.missed_count > 0; const paymentOnly = day.payments.length > 0 && day.bills_due.length === 0; return (
{moneyMarker && } {hasPaid && } {(hasDue || paymentOnly) && } {hasSkipped && } {hasMissed && }
); } function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) { const firstWeekday = new Date(data.year, data.month - 1, 1).getDay(); const cells = [ ...Array.from({ length: firstWeekday }, (_, index) => ({ type: 'blank', key: `blank-${index}` })), ...data.days.map(day => ({ type: 'day', key: day.date, day })), ]; const today = todayStr(); return (
{WEEKDAYS.map(day => (
{day}
))}
{cells.map(cell => { if (cell.type === 'blank') { return
; } const day = cell.day; const isToday = day.date === today; const isSelected = day.date === selectedDate; const summary = day.status_summary; const moneyMarker = moneyMarkers?.[day.date] || null; const hasActivity = day.bills_due.length > 0 || day.payments.length > 0 || !!moneyMarker; const isPaidDay = summary.due_count > 0 && summary.paid_count >= summary.due_count - summary.skipped_count; const hasMissed = summary.missed_count > 0; return ( ); })}
); } function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCount, totalCount, period, year, month }) { const navigate = useNavigate(); const isNegative = projected < 0; const amountPct = billsTotal > 0 ? Math.min(100, Math.round((paid / billsTotal) * 100)) : 0; const unpaidCount = totalCount - paidCount; const bucketParam = period === '1st' ? 'b1=1' : 'b2=1'; function goToUnpaid() { navigate(`/?un=1&${bucketParam}&year=${year}&month=${month}`); } return (

{label}

{isNegative ? '−' : ''}{fmt(Math.abs(projected))}

{/* Amount-based progress bar */}
{fmt(paid)} of {fmt(billsTotal)} paid {unpaidCount > 0 && ( )} {unpaidCount === 0 && totalCount > 0 && ( All paid ✓ )}

{fmt(starting)} starting · {fmt(billsTotal)} due

); } function CashFlowCard({ cashflow, year, month }) { if (!cashflow?.has_data) return null; const periodProjected = Number(cashflow.period_projected ?? 0); const monthProjected = Number(cashflow.month_projected ?? 0); // In the second half of the month the period end = month end — one panel suffices. // Only show the month panel separately in the first half where they differ. const showMonthPanel = cashflow.period === '1st'; const anyNegative = periodProjected < 0 || (showMonthPanel && monthProjected < 0); const shortfallAmount = showMonthPanel ? Math.min(periodProjected, monthProjected) : periodProjected; return (
Cash Flow Projection {cashflow.uses_bank_balance && ( Live balance )}
What you'll have after all bills clear — not just what's been paid so far.
{/* Negative balance alert */} {anyNegative && (
You're projected to be{' '} {fmt(Math.abs(shortfallAmount))} short {' '}by{' '}{cashflow.period_end_label}. Review unpaid bills or adjust your starting amounts.
)}
{showMonthPanel && ( )}
); } function DebtPayoffGlance({ projection }) { const snowball = projection?.snowball; const comparison = projection?.comparison; const targetDebt = snowball?.debts?.[0] || null; const targetMonths = Number(targetDebt?.months || 0); const monthsSaved = comparison?.months_saved; return (
Snowball Target
Focus
Current payoff focus, with the final debt-free date close by.
{snowball?.months_to_freedom ? (
{targetDebt && (

Target debt

{targetDebt.name}

Clears {targetDebt.payoff_display || 'on the current plan'}

Target runway

{targetMonths ? `${targetMonths} mo` : '—'}

Debt-free

{snowball.payoff_display}

)}

Time saved

{monthsSaved !== undefined ? `${monthsSaved} mo` : '—'}

Interest

{fmt(snowball.total_interest_paid)}

{!targetDebt && (

Projection ready

Open Snowball to review the active payoff order.

)}

Full plan

{snowball.months_to_freedom} mo

Interest saved

{comparison ? fmt(comparison.interest_saved) : '—'}

) : (

Choose a first target

Add debt balances and minimum payments to see the next payoff milestone here.

)}
); } function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) { return ( {day ? fmtDate(day.date) : 'Day details'}

Bills due and payments recorded for this date.

{day && (
{moneyMarker && (

Available Money

+{fmt(moneyMarker.amount)}

{moneyMarker.label}

)}

Bills Due

{day.bills_due.length === 0 ? (
No bills are due on this day.
) : (
{day.bills_due.map(bill => (

{bill.name}

{bill.category_name || 'Uncategorized'}

{displayStatus(bill.status)}

Expected

{fmt(bill.effective_amount)}

Paid

{fmt(bill.paid_amount)}

Due

{fmtDate(bill.due_date)}

))}
)}

Payments

{day.payments.length === 0 ? (
No payments were recorded on this day.
) : (
{day.payments.map(payment => (

{payment.bill_name}

{payment.method || 'Payment'}

{fmt(payment.amount)}
))}
)}
)}
); } export default function CalendarPage() { const initial = currentMonth(); const [year, setYear] = useState(initial.year); const [month, setMonth] = useState(initial.month); const [data, setData] = useState(null); const [summaryData, setSummaryData] = useState(null); const [snowballProjection, setSnowballProjection] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [selectedDay, setSelectedDay] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const load = useCallback(async () => { setLoading(true); setError(''); try { const [calendarResult, summaryResult, snowballResult] = await Promise.allSettled([ api.calendar(year, month), api.summary(year, month), api.snowballProjection(), ]); if (calendarResult.status === 'rejected') { throw calendarResult.reason; } const result = calendarResult.value; setData(result); setSummaryData(summaryResult.status === 'fulfilled' ? summaryResult.value : null); setSnowballProjection(snowballResult.status === 'fulfilled' ? snowballResult.value : null); setSelectedDay(current => current ? result.days.find(day => day.date === current.date) || null : null); } catch (err) { setError(err.message || 'Calendar data could not be loaded.'); toast.error(err.message || 'Calendar data could not be loaded.'); } finally { setLoading(false); } }, [year, month]); useEffect(() => { load(); }, [load]); const monthLabel = useMemo(() => `${MONTHS[month - 1]} ${year}`, [year, month]); const hasAnyBills = Number(data?.summary?.bill_count || 0) + Number(data?.summary?.skipped_count || 0) > 0; const moneyMarkers = useMemo(() => { const starting = summaryData?.starting_amounts; if (!starting) return {}; const markers = {}; const firstAmount = Number(starting.first_amount || 0); const fifteenthAmount = Number(starting.fifteenth_amount || 0); if (firstAmount > 0) { markers[`${year}-${String(month).padStart(2, '0')}-01`] = { label: '1st available', amount: firstAmount, }; } if (fifteenthAmount > 0) { markers[`${year}-${String(month).padStart(2, '0')}-15`] = { label: '15th available', amount: fifteenthAmount, }; } return markers; }, [month, summaryData, year]); const selectedMoneyMarker = selectedDay ? moneyMarkers[selectedDay.date] || null : null; function navigate(delta) { const next = shiftMonth(year, month, delta); setYear(next.year); setMonth(next.month); setSelectedDay(null); setDetailOpen(false); } function goToday() { const next = currentMonth(); setYear(next.year); setMonth(next.month); setSelectedDay(null); setDetailOpen(false); } return (

Monthly Calendar

Calendar

View bills, payments, and monthly progress by date.

{monthLabel}
Today
{loading && ( Loading calendar... )} {!loading && error && (

{error}

)} {!loading && !error && data && ( <> { setSelectedDay(day); setDetailOpen(true); }} /> {!hasAnyBills && (

No bills on this calendar yet.

Add a bill to start seeing due dates and payment progress.

)} )}
Selected Day Tap a date to inspect bills and payments. {selectedDay ? (

{fmtDate(selectedDay.date)}

Due

{fmt(selectedDay.status_summary.total_due)}

Paid

{fmt(selectedDay.status_summary.total_paid)}

) : (

No day selected.

)}
); }