BillTracker/client/pages/CalendarPage.jsx

957 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 asChild variant="outline" size="sm" className="gap-2">
<Link to="/settings#calendar-feed">
<CalendarDays className="h-4 w-4" />
Add All
</Link>
</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>
);
}