951 lines
41 KiB
JavaScript
951 lines
41 KiB
JavaScript
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 (
|
||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-foreground/75">
|
||
<span className={cn('h-2.5 w-2.5 rounded-full border', className)} />
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }) {
|
||
return (
|
||
<div className="min-w-0 rounded-lg border border-border/70 bg-background/70 p-3">
|
||
<div className="flex items-center gap-2">
|
||
<Icon className="h-4 w-4 text-foreground/70" />
|
||
<p className="truncate text-xs font-semibold uppercase tracking-wide text-foreground/70">{label}</p>
|
||
</div>
|
||
<p className={cn('tracker-number mt-2 text-xl font-bold tracking-tight', valueClassName || 'text-foreground')}>
|
||
{fmt(value)}
|
||
</p>
|
||
{hint && <p className="mt-1 truncate text-xs font-medium text-muted-foreground">{hint}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MoneyMap({ summaryData, loading }) {
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="grid gap-3 p-4 sm:grid-cols-2 xl:grid-cols-4">
|
||
{Array.from({ length: 4 }).map((_, index) => (
|
||
<div key={index} className="h-24 animate-pulse rounded-lg bg-muted" />
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Card className="overflow-hidden">
|
||
<CardHeader className="pb-3">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div>
|
||
<CardTitle className="text-base">Monthly Money Map</CardTitle>
|
||
<CardDescription>
|
||
{bankMode
|
||
? `Live bank balance · ${bt.account_name}`
|
||
: 'Available money, extra income, assigned bills, and what remains.'}
|
||
</CardDescription>
|
||
</div>
|
||
{!bankMode && (
|
||
<Button asChild variant="outline" size="sm" className="w-full gap-2 sm:w-auto">
|
||
<Link to="/summary">
|
||
<WalletCards className="h-4 w-4" />
|
||
Edit money plan
|
||
</Link>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||
{bankMode ? (
|
||
<>
|
||
<MoneyMetric icon={Banknote} label="Bank Balance" value={Number(bt.balance || 0)} hint={`as of last sync`} />
|
||
<MoneyMetric icon={PiggyBank} label="Pending" value={Number(bt.pending_payments || 0)} hint={`paid, not yet cleared (${bt.pending_days}d window)`} valueClassName="text-amber-600 dark:text-amber-400" />
|
||
<MoneyMetric icon={CalendarDays} label="Unpaid Bills" value={Number(bt.unpaid_this_month || 0)} hint={`${summary.expense_count || 0} active bills`} />
|
||
<MoneyMetric
|
||
icon={CircleDollarSign}
|
||
label="After Bills"
|
||
value={Number(bt.remaining || 0)}
|
||
hint="effective balance − unpaid"
|
||
valueClassName={Number(bt.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
<MoneyMetric icon={Banknote} label="Available" value={available} hint="1st + 15th + extra" />
|
||
<MoneyMetric icon={PiggyBank} label="Extra Income" value={extraIncome} hint="Extra beyond paychecks" valueClassName={extraIncome > 0 ? 'text-teal-600 dark:text-teal-300' : ''} />
|
||
<MoneyMetric icon={CalendarDays} label="Assigned Bills" value={assigned} hint={`${summary.expense_count || 0} active bills`} />
|
||
<MoneyMetric
|
||
icon={CircleDollarSign}
|
||
label="After Bills"
|
||
value={remaining}
|
||
hint={`${fmt(paid)} already paid`}
|
||
valueClassName={remaining >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{!bankMode && (
|
||
<div className="grid gap-2 text-sm md:grid-cols-3">
|
||
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/45 px-3 py-2">
|
||
<span className="text-muted-foreground">1st available</span>
|
||
<span className="tracker-number font-semibold">{fmt(starting.first_amount)}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/45 px-3 py-2">
|
||
<span className="text-muted-foreground">15th available</span>
|
||
<span className="tracker-number font-semibold">{fmt(starting.fifteenth_amount)}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/45 px-3 py-2">
|
||
<span className="text-muted-foreground">Monthly income</span>
|
||
<span className="tracker-number truncate font-semibold">{fmt(summaryData?.income?.amount)}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{bankMode && (
|
||
<div className={cn(
|
||
'flex items-center justify-between gap-3 rounded-xl border px-4 py-3',
|
||
Number(bt.remaining || 0) >= 0
|
||
? 'border-emerald-500/25 bg-emerald-500/5'
|
||
: 'border-destructive/25 bg-destructive/5',
|
||
)}>
|
||
<div className="space-y-0.5">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||
Projected Month-End Balance
|
||
</p>
|
||
<p className="text-[11px] text-muted-foreground/70">
|
||
{fmt(bt.balance || 0)} bank
|
||
{Number(bt.pending_payments || 0) > 0 && ` − ${fmt(bt.pending_payments)} pending`}
|
||
{` − ${fmt(bt.unpaid_this_month || 0)} remaining bills`}
|
||
</p>
|
||
</div>
|
||
<p className={cn(
|
||
'tracker-number text-2xl font-bold tabular-nums shrink-0',
|
||
Number(bt.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive',
|
||
)}>
|
||
{Number(bt.remaining || 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bt.remaining || 0)))}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{bankMode && bt.last_updated && (
|
||
<p className="text-xs text-muted-foreground">
|
||
Balance last updated: {new Date(bt.last_updated).toLocaleString()}
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function SummaryProgress({ summary }) {
|
||
const percent = Number(summary?.paid_percent || 0);
|
||
|
||
return (
|
||
<Card className="overflow-hidden">
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center gap-2">
|
||
<CircleDollarSign className="h-4 w-4 text-emerald-500" />
|
||
<CardTitle className="text-base">Total Expenses Paid</CardTitle>
|
||
</div>
|
||
<CardDescription>Monthly progress across active, unskipped bills.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex items-end justify-between gap-4">
|
||
<div>
|
||
<p className="tracker-number text-2xl font-semibold tracking-tight">
|
||
{fmt(summary?.paid_total)}
|
||
<span className="mx-2 text-sm font-normal text-muted-foreground">/</span>
|
||
<span className="text-base text-muted-foreground">{fmt(summary?.expected_total)}</span>
|
||
</p>
|
||
<p className="mt-1 text-sm text-muted-foreground">{fmt(summary?.remaining_total)} remaining</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-2xl font-semibold">{percent}%</p>
|
||
<p className="text-xs text-muted-foreground">paid</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 h-3 overflow-hidden rounded-full bg-muted">
|
||
<div
|
||
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
|
||
style={{ width: `${percent}%` }}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||
<span>{summary?.bill_count || 0} active bills</span>
|
||
<span>{summary?.paid_count || 0} paid</span>
|
||
{!!summary?.skipped_count && <span>{summary.skipped_count} skipped</span>}
|
||
{!!summary?.missed_count && <span className="text-destructive">{summary.missed_count} late or missed</span>}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="mt-auto flex flex-wrap gap-1">
|
||
{moneyMarker && <span className="h-1.5 w-1.5 rounded-full bg-teal-500 ring-1 ring-teal-500/30" title="Available money" />}
|
||
{hasPaid && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 ring-1 ring-emerald-400/30" title="Paid" />}
|
||
{(hasDue || paymentOnly) && <span className="h-1.5 w-1.5 rounded-full bg-primary" title="Due or payment" />}
|
||
{hasSkipped && <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" title="Skipped" />}
|
||
{hasMissed && <span className="h-2.5 w-2.5 rounded-full bg-rose-500 ring-2 ring-rose-500/40" title="Missed or late" />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Card className="overflow-hidden border-border/80 bg-card/95">
|
||
<div className="grid grid-cols-7 border-b border-border/70 bg-muted/45">
|
||
{WEEKDAYS.map(day => (
|
||
<div key={day} className="px-1 py-2 text-center text-[11px] font-semibold uppercase tracking-wide text-foreground/70 sm:text-xs">
|
||
{day}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-7">
|
||
{cells.map(cell => {
|
||
if (cell.type === 'blank') {
|
||
return <div key={cell.key} className="min-h-16 border-b border-r border-border/60 bg-background/30 sm:min-h-24" />;
|
||
}
|
||
|
||
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 (
|
||
<button
|
||
key={day.date}
|
||
type="button"
|
||
onClick={() => onSelectDay(day)}
|
||
className={cn(
|
||
'flex min-h-16 flex-col border-b border-r border-border/60 bg-card/70 p-1.5 text-left transition-colors sm:min-h-24 sm:p-2',
|
||
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||
hasActivity && 'bg-primary/[0.06] hover:bg-accent/70',
|
||
isPaidDay && 'bg-emerald-500/[0.10]',
|
||
hasMissed && 'border-rose-400/40 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.12]',
|
||
isSelected && 'ring-2 ring-primary ring-inset bg-primary/[0.09]',
|
||
)}
|
||
aria-label={`View ${fmtDate(day.date)}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-1">
|
||
<span className={cn(
|
||
'tracker-number flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold text-foreground/85 sm:text-sm',
|
||
isToday && 'border border-primary/60 bg-primary/15 text-primary',
|
||
)}>
|
||
{day.day}
|
||
</span>
|
||
{summary.due_count > 0 && (
|
||
<span className={cn(
|
||
'tracker-number rounded border px-1 text-[10px] font-semibold',
|
||
hasMissed
|
||
? 'border-rose-400/60 bg-rose-500/25 text-rose-700 dark:text-rose-100'
|
||
: 'border-border/60 bg-background/90 text-foreground/70',
|
||
)}>
|
||
{summary.due_count}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-1 hidden min-w-0 space-y-0.5 sm:block">
|
||
{moneyMarker && (
|
||
<p className="tracker-number truncate text-[11px] font-semibold text-teal-600 dark:text-teal-300">
|
||
+{fmt(moneyMarker.amount)}
|
||
</p>
|
||
)}
|
||
{day.bills_due.slice(0, 2).map(bill => (
|
||
<p key={bill.bill_id} className="truncate text-[11px] font-medium text-foreground/80">
|
||
{bill.name}
|
||
</p>
|
||
))}
|
||
{day.bills_due.length > 2 && (
|
||
<p className="text-[11px] font-medium text-muted-foreground">+{day.bills_due.length - 2} more</p>
|
||
)}
|
||
</div>
|
||
|
||
<DayIndicators day={day} moneyMarker={moneyMarker} />
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className={cn(
|
||
'rounded-xl border p-3 space-y-2',
|
||
isNegative
|
||
? 'border-destructive/30 bg-destructive/5'
|
||
: 'border-border/60 bg-muted/20',
|
||
)}>
|
||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||
{label}
|
||
</p>
|
||
<p className={cn(
|
||
'tracker-number text-2xl font-bold tabular-nums leading-none',
|
||
isNegative ? 'text-destructive' : 'text-foreground',
|
||
)}>
|
||
{isNegative ? '−' : ''}{fmt(Math.abs(projected))}
|
||
</p>
|
||
<div className="space-y-1.5">
|
||
{/* Amount-based progress bar */}
|
||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||
<span>{fmt(paid)} of {fmt(billsTotal)} paid</span>
|
||
{unpaidCount > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={goToUnpaid}
|
||
className="font-medium text-amber-600 underline-offset-2 hover:underline dark:text-amber-400"
|
||
title="View unpaid bills for this period"
|
||
>
|
||
{unpaidCount} unpaid →
|
||
</button>
|
||
)}
|
||
{unpaidCount === 0 && totalCount > 0 && (
|
||
<span className="text-emerald-600 dark:text-emerald-400">All paid ✓</span>
|
||
)}
|
||
</div>
|
||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||
<div
|
||
className={cn(
|
||
'h-full rounded-full transition-all duration-500',
|
||
amountPct === 100 ? 'bg-emerald-500' : isNegative ? 'bg-destructive/70' : 'bg-primary',
|
||
)}
|
||
style={{ width: `${amountPct}%` }}
|
||
/>
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
{fmt(starting)} starting · {fmt(billsTotal)} due
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Card className="overflow-hidden">
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-base">Cash Flow Projection</CardTitle>
|
||
{cashflow.uses_bank_balance && (
|
||
<span className="rounded-full border border-emerald-500/25 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||
Live balance
|
||
</span>
|
||
)}
|
||
</div>
|
||
<CardDescription>
|
||
What you'll have after all bills clear — not just what's been paid so far.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 space-y-3">
|
||
|
||
{/* Negative balance alert */}
|
||
{anyNegative && (
|
||
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2.5 text-sm text-destructive">
|
||
<span className="mt-0.5 shrink-0 text-base leading-none">⚠</span>
|
||
<span>
|
||
You're projected to be{' '}
|
||
<strong>{fmt(Math.abs(shortfallAmount))}</strong> short
|
||
{' '}by{' '}{cashflow.period_end_label}.
|
||
Review unpaid bills or adjust your starting amounts.
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className={cn('grid gap-3', showMonthPanel ? 'grid-cols-2' : 'grid-cols-1')}>
|
||
<ProjectionPanel
|
||
label={`By ${cashflow.period_end_label}`}
|
||
projected={periodProjected}
|
||
starting={cashflow.period_starting}
|
||
billsTotal={cashflow.period_bills_total}
|
||
paid={cashflow.period_paid}
|
||
paidCount={cashflow.period_paid_count}
|
||
totalCount={cashflow.period_total_count}
|
||
period={cashflow.period}
|
||
year={year}
|
||
month={month}
|
||
/>
|
||
{showMonthPanel && (
|
||
<ProjectionPanel
|
||
label="By month end"
|
||
projected={monthProjected}
|
||
starting={cashflow.month_starting}
|
||
billsTotal={cashflow.month_bills_total}
|
||
paid={cashflow.month_paid}
|
||
paidCount={cashflow.month_paid_count}
|
||
totalCount={cashflow.month_total_count}
|
||
period={cashflow.period === '1st' ? '15th' : '1st'}
|
||
year={year}
|
||
month={month}
|
||
/>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Card className="overflow-hidden">
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<Target className="h-4 w-4 text-emerald-500" />
|
||
<CardTitle className="text-base">Snowball Target</CardTitle>
|
||
</div>
|
||
<Badge variant="outline" className="border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300">
|
||
Focus
|
||
</Badge>
|
||
</div>
|
||
<CardDescription>Current payoff focus, with the final debt-free date close by.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{snowball?.months_to_freedom ? (
|
||
<div className="space-y-3">
|
||
{targetDebt && (
|
||
<div className="rounded-xl border border-emerald-500/25 bg-emerald-500/[0.08] p-3">
|
||
<div className="flex items-start gap-3">
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-300">
|
||
<Target className="h-5 w-5" />
|
||
</span>
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-300">
|
||
Target debt
|
||
</p>
|
||
<p className="mt-1 truncate text-lg font-semibold tracking-tight">{targetDebt.name}</p>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
Clears {targetDebt.payoff_display || 'on the current plan'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-2 gap-2 text-sm">
|
||
<div className="rounded-lg border border-emerald-500/15 bg-background/65 p-3">
|
||
<p className="text-xs text-muted-foreground">Target runway</p>
|
||
<p className="tracker-number font-semibold text-emerald-600 dark:text-emerald-300">
|
||
{targetMonths ? `${targetMonths} mo` : '—'}
|
||
</p>
|
||
</div>
|
||
<div className="rounded-lg border border-sky-500/15 bg-sky-500/[0.08] p-3">
|
||
<p className="text-xs text-muted-foreground">Debt-free</p>
|
||
<p className="font-semibold text-sky-600 dark:text-sky-300">{snowball.payoff_display}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||
<div className="rounded-lg border border-border/60 bg-muted/35 p-3">
|
||
<div className="flex items-center gap-2">
|
||
<Trophy className="h-4 w-4 text-amber-500" />
|
||
<p className="text-xs text-muted-foreground">Time saved</p>
|
||
</div>
|
||
<p className="tracker-number mt-2 font-semibold text-amber-600 dark:text-amber-300">
|
||
{monthsSaved !== undefined ? `${monthsSaved} mo` : '—'}
|
||
</p>
|
||
</div>
|
||
<div className="rounded-lg border border-border/60 bg-muted/35 p-3">
|
||
<div className="flex items-center gap-2">
|
||
<TrendingDown className="h-4 w-4 text-teal-500" />
|
||
<p className="text-xs text-muted-foreground">Interest</p>
|
||
</div>
|
||
<p className="tracker-number mt-2 font-semibold">{fmt(snowball.total_interest_paid)}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{!targetDebt && (
|
||
<div className="rounded-lg border border-sky-500/25 bg-sky-500/[0.08] p-3">
|
||
<p className="text-sm font-medium text-sky-700 dark:text-sky-300">Projection ready</p>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
Open Snowball to review the active payoff order.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||
<div className="rounded-lg bg-muted/30 p-3">
|
||
<p className="text-xs text-muted-foreground">Full plan</p>
|
||
<p className="tracker-number font-semibold">{snowball.months_to_freedom} mo</p>
|
||
</div>
|
||
<div className="rounded-lg bg-muted/30 p-3">
|
||
<p className="text-xs text-muted-foreground">Interest saved</p>
|
||
<p className="tracker-number font-semibold text-emerald-600 dark:text-emerald-300">
|
||
{comparison ? fmt(comparison.interest_saved) : '—'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Button asChild variant="outline" size="sm" className="w-full border-emerald-500/30 text-emerald-700 hover:bg-emerald-500/10 dark:text-emerald-300">
|
||
<Link to="/snowball">Open Snowball</Link>
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3 rounded-xl border border-sky-500/25 bg-sky-500/[0.08] p-3">
|
||
<div className="flex items-start gap-3">
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sky-600 dark:text-sky-300">
|
||
<Target className="h-5 w-5" />
|
||
</span>
|
||
<div>
|
||
<p className="text-sm font-medium">Choose a first target</p>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
Add debt balances and minimum payments to see the next payoff milestone here.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Button asChild variant="outline" size="sm" className="w-full border-sky-500/30 text-sky-700 hover:bg-sky-500/10 dark:text-sky-300">
|
||
<Link to="/snowball">Set up Snowball</Link>
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) {
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-base font-semibold">{day ? fmtDate(day.date) : 'Day details'}</DialogTitle>
|
||
<p className="text-sm text-muted-foreground">Bills due and payments recorded for this date.</p>
|
||
</DialogHeader>
|
||
|
||
{day && (
|
||
<div className="space-y-5">
|
||
{moneyMarker && (
|
||
<section className="rounded-lg border border-teal-500/25 bg-teal-500/10 p-3">
|
||
<h3 className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-300">
|
||
Available Money
|
||
</h3>
|
||
<p className="tracker-number mt-1 text-lg font-semibold text-teal-600 dark:text-teal-300">
|
||
+{fmt(moneyMarker.amount)}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">{moneyMarker.label}</p>
|
||
</section>
|
||
)}
|
||
<section>
|
||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bills Due</h3>
|
||
{day.bills_due.length === 0 ? (
|
||
<div className="rounded-lg bg-muted/15 p-4 text-sm text-muted-foreground">
|
||
No bills due this day.
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{day.bills_due.map(bill => (
|
||
<div
|
||
key={bill.bill_id}
|
||
className={cn(
|
||
'rounded-lg border border-border/70 bg-background/75 p-3',
|
||
(bill.status === 'late' || bill.status === 'missed') &&
|
||
'border-rose-400/40 bg-rose-500/[0.10] ring-1 ring-inset ring-rose-400/20',
|
||
)}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<p className="truncate text-sm font-semibold text-foreground">{bill.name}</p>
|
||
<p className="mt-0.5 text-xs font-medium text-muted-foreground">{bill.category_name || 'Uncategorized'}</p>
|
||
</div>
|
||
<Badge variant="outline" className={cn('shrink-0 capitalize', statusTone(bill.status))}>
|
||
{displayStatus(bill.status)}
|
||
</Badge>
|
||
</div>
|
||
<div className="mt-3 grid grid-cols-3 gap-2 text-xs font-medium text-muted-foreground">
|
||
<div>
|
||
<p>Expected</p>
|
||
<p className="tracker-number text-sm font-semibold text-foreground">{fmt(bill.effective_amount)}</p>
|
||
</div>
|
||
<div>
|
||
<p>Paid</p>
|
||
<p className="tracker-number text-sm font-semibold text-emerald-600 dark:text-emerald-300">{fmt(bill.paid_amount)}</p>
|
||
</div>
|
||
<div>
|
||
<p>Due</p>
|
||
<p className="tracker-number text-sm font-semibold text-foreground">{fmtDate(bill.due_date)}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section>
|
||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Payments</h3>
|
||
{day.payments.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
|
||
No payments were recorded on this day.
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{day.payments.map(payment => (
|
||
<div key={payment.payment_id} className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-background/75 p-3">
|
||
<div className="min-w-0">
|
||
<p className="truncate text-sm font-semibold text-foreground">{payment.bill_name}</p>
|
||
<p className="text-xs font-medium text-muted-foreground">{payment.method || 'Payment'}</p>
|
||
</div>
|
||
<span className="tracker-number text-sm font-semibold text-emerald-600 dark:text-emerald-300">{fmt(payment.amount)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 pt-4">
|
||
<Button asChild variant="outline" size="sm">
|
||
<Link to="/">Open Tracker</Link>
|
||
</Button>
|
||
<Button asChild size="sm">
|
||
<Link to="/bills">Manage Bills</Link>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="space-y-5">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
|
||
Monthly Calendar
|
||
</p>
|
||
<h1 className="text-3xl font-semibold tracking-tight">Calendar</h1>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
View bills, payments, and monthly progress by date.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="flex items-center rounded-full border border-border/70 bg-card/90 p-1 shadow-sm">
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(-1)} aria-label="Previous month">
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
<span className="min-w-[8rem] px-1 text-center text-sm font-semibold tabular-nums select-none">{monthLabel}</span>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(1)} aria-label="Next month">
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<Button variant="outline" size="sm" onClick={goToday}>Today</Button>
|
||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-full" onClick={load} aria-label="Refresh calendar">
|
||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<MoneyMap summaryData={summaryData} loading={loading} />
|
||
|
||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border/70 bg-card/70 px-4 py-3">
|
||
<LegendItem className="border-emerald-500 bg-emerald-500" label="Paid" />
|
||
<LegendItem className="border-primary bg-primary" label="Due" />
|
||
<LegendItem className="border-teal-500 bg-teal-500 ring-1 ring-teal-500/30" label="Available money" />
|
||
<LegendItem className="border-muted-foreground/50 bg-muted-foreground/50" label="Skipped" />
|
||
<LegendItem className="border-destructive bg-destructive" label="Missed/Late" />
|
||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-foreground/75">
|
||
<span className="h-5 w-5 rounded-full border border-primary bg-primary/10" />
|
||
Today
|
||
</span>
|
||
</div>
|
||
|
||
{loading && (
|
||
<Card>
|
||
<CardContent className="flex min-h-[360px] items-center justify-center p-6 text-sm text-muted-foreground">
|
||
Loading calendar...
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{!loading && error && (
|
||
<Card>
|
||
<CardContent className="flex min-h-[260px] flex-col items-center justify-center gap-3 p-6 text-center">
|
||
<p className="text-sm text-destructive">{error}</p>
|
||
<Button variant="outline" size="sm" onClick={load}>Try again</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{!loading && !error && data && (
|
||
<>
|
||
<CalendarGrid
|
||
data={data}
|
||
selectedDate={selectedDay?.date}
|
||
moneyMarkers={moneyMarkers}
|
||
onSelectDay={day => {
|
||
setSelectedDay(day);
|
||
setDetailOpen(true);
|
||
}}
|
||
/>
|
||
{!hasAnyBills && (
|
||
<Card>
|
||
<CardContent className="flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||
<CalendarDays className="h-8 w-8 text-muted-foreground" />
|
||
<div>
|
||
<p className="text-sm font-medium">No bills on this calendar yet.</p>
|
||
<p className="mt-1 text-sm text-muted-foreground">Add a bill to start seeing due dates and payment progress.</p>
|
||
</div>
|
||
<Button asChild size="sm">
|
||
<Link to="/bills">Add bill</Link>
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<SummaryProgress summary={data?.summary} />
|
||
<CashFlowCard cashflow={data?.cashflow} year={year} month={month} />
|
||
<DebtPayoffGlance projection={snowballProjection} />
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base">Selected Day</CardTitle>
|
||
<CardDescription>Tap a date to inspect bills and payments.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{selectedDay ? (
|
||
<div className="space-y-3">
|
||
<p className="text-sm font-semibold">{fmtDate(selectedDay.date)}</p>
|
||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||
<div className="rounded-lg bg-muted/40 p-3">
|
||
<p className="text-xs text-muted-foreground">Due</p>
|
||
<p className="tracker-number font-semibold">{fmt(selectedDay.status_summary.total_due)}</p>
|
||
</div>
|
||
<div className="rounded-lg bg-muted/40 p-3">
|
||
<p className="text-xs text-muted-foreground">Paid</p>
|
||
<p className="tracker-number font-semibold text-emerald-600 dark:text-emerald-300">{fmt(selectedDay.status_summary.total_paid)}</p>
|
||
</div>
|
||
</div>
|
||
<Button className="w-full" size="sm" onClick={() => setDetailOpen(true)}>
|
||
View day details
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">No day selected.</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
|
||
<DayDetailDialog
|
||
day={selectedDay}
|
||
moneyMarker={selectedMoneyMarker}
|
||
open={detailOpen}
|
||
onOpenChange={setDetailOpen}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|