2026-07-06 14:23:53 -05:00
|
|
|
|
import {
|
|
|
|
|
|
useCallback,
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useMemo,
|
|
|
|
|
|
useState,
|
|
|
|
|
|
type ComponentType,
|
|
|
|
|
|
type ReactNode,
|
|
|
|
|
|
} from 'react';
|
2026-07-05 14:49:09 -05:00
|
|
|
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
2026-05-16 20:26:09 -05:00
|
|
|
|
import {
|
|
|
|
|
|
Banknote,
|
|
|
|
|
|
CalendarDays,
|
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
|
ChevronRight,
|
|
|
|
|
|
CircleDollarSign,
|
|
|
|
|
|
PiggyBank,
|
|
|
|
|
|
RefreshCw,
|
2026-05-18 09:44:16 -05:00
|
|
|
|
Target,
|
2026-05-16 20:26:09 -05:00
|
|
|
|
TrendingDown,
|
2026-05-18 09:44:16 -05:00
|
|
|
|
Trophy,
|
2026-05-16 20:26:09 -05:00
|
|
|
|
WalletCards,
|
|
|
|
|
|
} from 'lucide-react';
|
2026-05-04 13:14:32 -05:00
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
import { api } from '@/api';
|
2026-07-04 22:58:34 -05:00
|
|
|
|
import { cn, fmtDate, todayStr, errMessage } from '@/lib/utils';
|
|
|
|
|
|
import { formatUSD, asDollars } from '@/lib/money';
|
2026-07-03 18:36:30 -05:00
|
|
|
|
import { isPaidStatus } from '@/lib/trackerUtils';
|
2026-07-05 15:57:35 -05:00
|
|
|
|
import { useSettings } from '@/hooks/useQueries';
|
2026-05-04 13:14:32 -05:00
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
|
import { Badge } from '@/components/ui/badge';
|
|
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
2026-07-06 14:23:53 -05:00
|
|
|
|
import {
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
DialogContent,
|
|
|
|
|
|
DialogDescription,
|
|
|
|
|
|
DialogHeader,
|
|
|
|
|
|
DialogTitle,
|
|
|
|
|
|
} from '@/components/ui/dialog';
|
2026-06-07 16:52:50 -05:00
|
|
|
|
import { CalendarFeedManager } from '@/components/CalendarFeedManager';
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
2026-07-04 22:58:34 -05:00
|
|
|
|
function fmt(v: number | null | undefined): string {
|
|
|
|
|
|
return formatUSD(asDollars(Number(v) || 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface StatusSummary {
|
|
|
|
|
|
paid_count: number;
|
|
|
|
|
|
due_count: number;
|
|
|
|
|
|
skipped_count: number;
|
|
|
|
|
|
missed_count: number;
|
|
|
|
|
|
total_due?: number;
|
|
|
|
|
|
total_paid?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CalDayBill {
|
|
|
|
|
|
bill_id: number;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
category_name?: string | null;
|
|
|
|
|
|
status?: string;
|
|
|
|
|
|
effective_amount?: number;
|
|
|
|
|
|
paid_amount?: number;
|
|
|
|
|
|
due_date?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CalDayPayment {
|
|
|
|
|
|
payment_id: number;
|
|
|
|
|
|
bill_name?: string;
|
|
|
|
|
|
method?: string;
|
|
|
|
|
|
amount?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CalDay {
|
|
|
|
|
|
date: string;
|
|
|
|
|
|
day: number;
|
|
|
|
|
|
status_summary: StatusSummary;
|
|
|
|
|
|
payments: CalDayPayment[];
|
|
|
|
|
|
bills_due: CalDayBill[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface SummaryInfo {
|
|
|
|
|
|
bill_count?: number;
|
|
|
|
|
|
paid_count?: number;
|
|
|
|
|
|
skipped_count?: number;
|
|
|
|
|
|
missed_count?: number;
|
|
|
|
|
|
paid_total?: number;
|
|
|
|
|
|
expected_total?: number;
|
|
|
|
|
|
remaining_total?: number;
|
|
|
|
|
|
paid_percent?: number;
|
|
|
|
|
|
expense_total?: number;
|
|
|
|
|
|
expense_count?: number;
|
|
|
|
|
|
result?: number;
|
|
|
|
|
|
[key: string]: unknown;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface Cashflow {
|
|
|
|
|
|
has_data?: boolean;
|
|
|
|
|
|
period?: string;
|
|
|
|
|
|
period_projected?: number;
|
|
|
|
|
|
month_projected?: number;
|
|
|
|
|
|
uses_bank_balance?: boolean;
|
|
|
|
|
|
period_end_label?: string;
|
|
|
|
|
|
period_starting?: number;
|
|
|
|
|
|
period_bills_total?: number;
|
|
|
|
|
|
period_paid?: number;
|
|
|
|
|
|
period_paid_count?: number;
|
|
|
|
|
|
period_total_count?: number;
|
|
|
|
|
|
month_starting?: number;
|
|
|
|
|
|
month_bills_total?: number;
|
|
|
|
|
|
month_paid?: number;
|
|
|
|
|
|
month_paid_count?: number;
|
|
|
|
|
|
month_total_count?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CalendarData {
|
|
|
|
|
|
year: number;
|
|
|
|
|
|
month: number;
|
|
|
|
|
|
days: CalDay[];
|
|
|
|
|
|
summary?: SummaryInfo;
|
|
|
|
|
|
cashflow?: Cashflow;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface BankTracking {
|
|
|
|
|
|
enabled?: boolean;
|
|
|
|
|
|
effective_balance?: number;
|
|
|
|
|
|
account_name?: string;
|
|
|
|
|
|
balance?: number;
|
|
|
|
|
|
pending_payments?: number;
|
|
|
|
|
|
pending_days?: number;
|
|
|
|
|
|
unpaid_this_month?: number;
|
|
|
|
|
|
remaining?: number;
|
|
|
|
|
|
last_updated?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface SummaryDataShape {
|
|
|
|
|
|
starting_amounts?: {
|
|
|
|
|
|
first_amount?: number;
|
|
|
|
|
|
fifteenth_amount?: number;
|
|
|
|
|
|
other_amount?: number;
|
|
|
|
|
|
combined_amount?: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
summary?: SummaryInfo;
|
|
|
|
|
|
bank_tracking?: BankTracking;
|
|
|
|
|
|
income?: { amount?: number };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
interface SnowballDebt {
|
|
|
|
|
|
name?: string;
|
|
|
|
|
|
months?: number;
|
|
|
|
|
|
payoff_display?: string;
|
|
|
|
|
|
}
|
2026-07-04 22:58:34 -05:00
|
|
|
|
interface SnowballProjection {
|
|
|
|
|
|
snowball?: {
|
|
|
|
|
|
debts?: SnowballDebt[];
|
|
|
|
|
|
months_to_freedom?: number;
|
|
|
|
|
|
payoff_display?: string;
|
|
|
|
|
|
total_interest_paid?: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
comparison?: { months_saved?: number; interest_saved?: number };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
interface MoneyMarker {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
amount: number;
|
|
|
|
|
|
}
|
2026-07-04 22:58:34 -05:00
|
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const MONTHS = [
|
2026-07-06 14:23:53 -05:00
|
|
|
|
'January',
|
|
|
|
|
|
'February',
|
|
|
|
|
|
'March',
|
|
|
|
|
|
'April',
|
|
|
|
|
|
'May',
|
|
|
|
|
|
'June',
|
|
|
|
|
|
'July',
|
|
|
|
|
|
'August',
|
|
|
|
|
|
'September',
|
|
|
|
|
|
'October',
|
|
|
|
|
|
'November',
|
|
|
|
|
|
'December',
|
2026-05-04 13:14:32 -05:00
|
|
|
|
];
|
|
|
|
|
|
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
|
|
|
|
|
|
|
|
|
|
function currentMonth() {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:58:34 -05:00
|
|
|
|
function shiftMonth(year: number, month: number, delta: number) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const next = new Date(year, month - 1 + delta, 1);
|
|
|
|
|
|
return { year: next.getFullYear(), month: next.getMonth() + 1 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:58:34 -05:00
|
|
|
|
function displayStatus(status?: string): string {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
if (status === 'due_soon') return 'Due';
|
|
|
|
|
|
if (status === 'late') return 'Late';
|
|
|
|
|
|
return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Due';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:58:34 -05:00
|
|
|
|
function statusTone(status?: string): string {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
if (isPaidStatus(status || ''))
|
|
|
|
|
|
return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300';
|
2026-05-28 21:40:27 -05:00
|
|
|
|
if (status === 'skipped') return 'border-border bg-muted/80 text-muted-foreground';
|
2026-07-06 14:23:53 -05:00
|
|
|
|
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';
|
2026-05-28 21:40:27 -05:00
|
|
|
|
return 'border-primary/30 bg-primary/15 text-primary';
|
2026-05-04 13:14:32 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:58:34 -05:00
|
|
|
|
function LegendItem({ className, label }: { className?: string; label: ReactNode }) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
return (
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-foreground/75">
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<span className={cn('h-2.5 w-2.5 rounded-full border', className)} />
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function MoneyMetric({
|
|
|
|
|
|
icon: Icon,
|
|
|
|
|
|
label,
|
|
|
|
|
|
value,
|
|
|
|
|
|
hint,
|
|
|
|
|
|
valueClassName,
|
|
|
|
|
|
}: {
|
2026-07-04 22:58:34 -05:00
|
|
|
|
icon: ComponentType<{ className?: string }>;
|
|
|
|
|
|
label: ReactNode;
|
|
|
|
|
|
value: number | null | undefined;
|
|
|
|
|
|
hint?: ReactNode;
|
|
|
|
|
|
valueClassName?: string;
|
|
|
|
|
|
}) {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
return (
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<div className="min-w-0 rounded-lg border border-border/70 bg-background/70 p-3">
|
2026-05-16 20:26:09 -05:00
|
|
|
|
<div className="flex items-center gap-2">
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<Icon className="h-4 w-4 text-foreground/70" />
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="truncate text-xs font-semibold uppercase tracking-wide text-foreground/70">
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</p>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'tracker-number mt-2 text-xl font-bold tracking-tight',
|
|
|
|
|
|
valueClassName || 'text-foreground',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
{fmt(value)}
|
|
|
|
|
|
</p>
|
2026-05-28 21:40:27 -05:00
|
|
|
|
{hint && <p className="mt-1 truncate text-xs font-medium text-muted-foreground">{hint}</p>}
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function MoneyMap({
|
|
|
|
|
|
summaryData,
|
|
|
|
|
|
loading,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
summaryData: SummaryDataShape | null;
|
|
|
|
|
|
loading: boolean;
|
|
|
|
|
|
}) {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
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 || {};
|
2026-06-03 21:09:26 -05:00
|
|
|
|
const bt = summaryData?.bank_tracking;
|
|
|
|
|
|
const bankMode = bt?.enabled === true;
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const available = bankMode
|
|
|
|
|
|
? Number(bt?.effective_balance || 0)
|
|
|
|
|
|
: Number(starting.combined_amount || 0);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
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>
|
2026-06-03 21:09:26 -05:00
|
|
|
|
<CardDescription>
|
|
|
|
|
|
{bankMode
|
2026-07-04 22:58:34 -05:00
|
|
|
|
? `Live bank balance · ${bt?.account_name}`
|
2026-06-03 21:09:26 -05:00
|
|
|
|
: 'Available money, extra income, assigned bills, and what remains.'}
|
|
|
|
|
|
</CardDescription>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</div>
|
2026-06-03 21:09:26 -05:00
|
|
|
|
{!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>
|
|
|
|
|
|
)}
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
2026-06-03 21:09:26 -05:00
|
|
|
|
{bankMode ? (
|
|
|
|
|
|
<>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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`}
|
|
|
|
|
|
/>
|
2026-06-03 21:09:26 -05:00
|
|
|
|
<MoneyMetric
|
|
|
|
|
|
icon={CircleDollarSign}
|
|
|
|
|
|
label="After Bills"
|
2026-07-04 22:58:34 -05:00
|
|
|
|
value={Number(bt?.remaining || 0)}
|
2026-06-03 21:09:26 -05:00
|
|
|
|
hint="effective balance − unpaid"
|
2026-07-06 14:23:53 -05:00
|
|
|
|
valueClassName={
|
|
|
|
|
|
Number(bt?.remaining || 0) >= 0
|
|
|
|
|
|
? 'text-emerald-600 dark:text-emerald-300'
|
|
|
|
|
|
: 'text-destructive'
|
|
|
|
|
|
}
|
2026-06-03 21:09:26 -05:00
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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`}
|
|
|
|
|
|
/>
|
2026-06-03 21:09:26 -05:00
|
|
|
|
<MoneyMetric
|
|
|
|
|
|
icon={CircleDollarSign}
|
|
|
|
|
|
label="After Bills"
|
|
|
|
|
|
value={remaining}
|
|
|
|
|
|
hint={`${fmt(paid)} already paid`}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
valueClassName={
|
|
|
|
|
|
remaining >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'
|
|
|
|
|
|
}
|
2026-06-03 21:09:26 -05:00
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-03 21:09:26 -05:00
|
|
|
|
{!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>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<span className="tracker-number truncate font-semibold">
|
|
|
|
|
|
{fmt(summaryData?.income?.amount)}
|
|
|
|
|
|
</span>
|
2026-06-03 21:09:26 -05:00
|
|
|
|
</div>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</div>
|
2026-06-03 21:09:26 -05:00
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-04 01:36:07 -05:00
|
|
|
|
{bankMode && (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-06-04 01:36:07 -05:00
|
|
|
|
<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">
|
2026-07-04 22:58:34 -05:00
|
|
|
|
{fmt(bt?.balance || 0)} bank
|
|
|
|
|
|
{Number(bt?.pending_payments || 0) > 0 && ` − ${fmt(bt?.pending_payments)} pending`}
|
|
|
|
|
|
{` − ${fmt(bt?.unpaid_this_month || 0)} remaining bills`}
|
2026-06-04 01:36:07 -05:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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)))}
|
2026-06-04 01:36:07 -05:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-04 22:58:34 -05:00
|
|
|
|
{bankMode && bt?.last_updated && (
|
2026-06-03 21:09:26 -05:00
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
Balance last updated: {new Date(bt.last_updated).toLocaleString()}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:58:34 -05:00
|
|
|
|
function SummaryProgress({ summary }: { summary?: SummaryInfo }) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
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>
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<p className="tracker-number text-2xl font-semibold tracking-tight">
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{fmt(summary?.paid_total)}
|
|
|
|
|
|
<span className="mx-2 text-sm font-normal text-muted-foreground">/</span>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</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>}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{!!summary?.missed_count && (
|
|
|
|
|
|
<span className="text-destructive">{summary.missed_count} late or missed</span>
|
|
|
|
|
|
)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:58:34 -05:00
|
|
|
|
function DayIndicators({ day, moneyMarker }: { day: CalDay; moneyMarker: MoneyMarker | null }) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const summary = day.status_summary;
|
|
|
|
|
|
const hasPaid = summary.paid_count > 0;
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const hasDue =
|
|
|
|
|
|
summary.due_count > summary.paid_count + summary.skipped_count + summary.missed_count;
|
2026-05-04 13:14:32 -05:00
|
|
|
|
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">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function CalendarGrid({
|
|
|
|
|
|
data,
|
|
|
|
|
|
selectedDate,
|
|
|
|
|
|
onSelectDay,
|
|
|
|
|
|
moneyMarkers,
|
|
|
|
|
|
weekStart = 0,
|
|
|
|
|
|
}: {
|
2026-07-04 22:58:34 -05:00
|
|
|
|
data: CalendarData;
|
|
|
|
|
|
selectedDate?: string;
|
|
|
|
|
|
onSelectDay: (day: CalDay) => void;
|
|
|
|
|
|
moneyMarkers?: Record<string, MoneyMarker>;
|
2026-07-05 15:57:35 -05:00
|
|
|
|
weekStart?: number;
|
2026-07-04 22:58:34 -05:00
|
|
|
|
}) {
|
2026-07-05 15:57:35 -05:00
|
|
|
|
// Offset the leading blanks + rotate the header so the week can start on
|
|
|
|
|
|
// Sunday (0) or Monday (1) per the user's setting.
|
|
|
|
|
|
const firstWeekday = (new Date(data.year, data.month - 1, 1).getDay() - weekStart + 7) % 7;
|
|
|
|
|
|
const orderedWeekdays = [...WEEKDAYS.slice(weekStart), ...WEEKDAYS.slice(0, weekStart)];
|
2026-07-04 22:58:34 -05:00
|
|
|
|
const cells: ({ type: 'blank'; key: string } | { type: 'day'; key: string; day: CalDay })[] = [
|
2026-07-06 14:23:53 -05:00
|
|
|
|
...Array.from({ length: firstWeekday }, (_, index) => ({
|
|
|
|
|
|
type: 'blank' as const,
|
|
|
|
|
|
key: `blank-${index}`,
|
|
|
|
|
|
})),
|
|
|
|
|
|
...data.days.map((day) => ({ type: 'day' as const, key: day.date, day })),
|
2026-05-04 13:14:32 -05:00
|
|
|
|
];
|
|
|
|
|
|
const today = todayStr();
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<Card className="overflow-hidden border-border/80 bg-card/95">
|
|
|
|
|
|
<div className="grid grid-cols-7 border-b border-border/70 bg-muted/45">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{orderedWeekdays.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"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{day}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-7">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{cells.map((cell) => {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
if (cell.type === 'blank') {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={cell.key}
|
|
|
|
|
|
className="min-h-16 border-b border-r border-border/60 bg-background/30 sm:min-h-24"
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const day = cell.day;
|
|
|
|
|
|
const isToday = day.date === today;
|
|
|
|
|
|
const isSelected = day.date === selectedDate;
|
|
|
|
|
|
const summary = day.status_summary;
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const moneyMarker = moneyMarkers?.[day.date] || null;
|
|
|
|
|
|
const hasActivity = day.bills_due.length > 0 || day.payments.length > 0 || !!moneyMarker;
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const isPaidDay =
|
|
|
|
|
|
summary.due_count > 0 &&
|
|
|
|
|
|
summary.paid_count >= summary.due_count - summary.skipped_count;
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const hasMissed = summary.missed_count > 0;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={day.date}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => onSelectDay(day)}
|
|
|
|
|
|
className={cn(
|
2026-05-28 21:40:27 -05:00
|
|
|
|
'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',
|
2026-05-04 13:14:32 -05:00
|
|
|
|
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
2026-07-10 18:03:15 -05:00
|
|
|
|
hasActivity && 'bg-primary/6 hover:bg-accent/70',
|
|
|
|
|
|
isPaidDay && 'bg-emerald-500/10',
|
2026-07-06 14:23:53 -05:00
|
|
|
|
hasMissed &&
|
2026-07-10 18:03:15 -05:00
|
|
|
|
'border-rose-400/40 bg-rose-500/18 ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/12',
|
|
|
|
|
|
isSelected && 'ring-2 ring-primary ring-inset bg-primary/9',
|
2026-05-04 13:14:32 -05:00
|
|
|
|
)}
|
|
|
|
|
|
aria-label={`View ${fmtDate(day.date)}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-start justify-between gap-1">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{day.day}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{summary.due_count > 0 && (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{summary.due_count}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-1 hidden min-w-0 space-y-0.5 sm:block">
|
2026-05-16 20:26:09 -05:00
|
|
|
|
{moneyMarker && (
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<p className="tracker-number truncate text-[11px] font-semibold text-teal-600 dark:text-teal-300">
|
2026-05-16 20:26:09 -05:00
|
|
|
|
+{fmt(moneyMarker.amount)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{day.bills_due.slice(0, 2).map((bill) => (
|
|
|
|
|
|
<p
|
|
|
|
|
|
key={bill.bill_id}
|
|
|
|
|
|
className="truncate text-[11px] font-medium text-foreground/80"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{bill.name}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{day.bills_due.length > 2 && (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="text-[11px] font-medium text-muted-foreground">
|
|
|
|
|
|
+{day.bills_due.length - 2} more
|
|
|
|
|
|
</p>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
|
<DayIndicators day={day} moneyMarker={moneyMarker} />
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function ProjectionPanel({
|
|
|
|
|
|
label,
|
|
|
|
|
|
projected,
|
|
|
|
|
|
starting,
|
|
|
|
|
|
billsTotal,
|
|
|
|
|
|
paid,
|
|
|
|
|
|
paidCount,
|
|
|
|
|
|
totalCount,
|
|
|
|
|
|
period,
|
|
|
|
|
|
year,
|
|
|
|
|
|
month,
|
|
|
|
|
|
}: {
|
2026-07-04 22:58:34 -05:00
|
|
|
|
label: ReactNode;
|
|
|
|
|
|
projected: number;
|
|
|
|
|
|
starting?: number;
|
|
|
|
|
|
billsTotal?: number;
|
|
|
|
|
|
paid?: number;
|
|
|
|
|
|
paidCount?: number;
|
|
|
|
|
|
totalCount?: number;
|
|
|
|
|
|
period?: string;
|
|
|
|
|
|
year: number;
|
|
|
|
|
|
month: number;
|
|
|
|
|
|
}) {
|
2026-06-03 21:30:02 -05:00
|
|
|
|
const navigate = useNavigate();
|
2026-07-04 22:58:34 -05:00
|
|
|
|
const billsTotalN = Number(billsTotal || 0);
|
|
|
|
|
|
const paidN = Number(paid || 0);
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const isNegative = projected < 0;
|
|
|
|
|
|
const amountPct = billsTotalN > 0 ? Math.min(100, Math.round((paidN / billsTotalN) * 100)) : 0;
|
|
|
|
|
|
const unpaidCount = Number(totalCount || 0) - Number(paidCount || 0);
|
|
|
|
|
|
const bucketParam = period === '1st' ? 'b1=1' : 'b2=1';
|
2026-06-03 21:30:02 -05:00
|
|
|
|
|
|
|
|
|
|
function goToUnpaid() {
|
|
|
|
|
|
navigate(`/?un=1&${bucketParam}&year=${year}&month=${month}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'rounded-xl border p-3 space-y-2',
|
|
|
|
|
|
isNegative ? 'border-destructive/30 bg-destructive/5' : 'border-border/60 bg-muted/20',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-06-03 21:30:02 -05:00
|
|
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</p>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'tracker-number text-2xl font-bold tabular-nums leading-none',
|
|
|
|
|
|
isNegative ? 'text-destructive' : 'text-foreground',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isNegative ? '−' : ''}
|
|
|
|
|
|
{fmt(Math.abs(projected))}
|
2026-06-03 21:30:02 -05:00
|
|
|
|
</p>
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
{/* Amount-based progress bar */}
|
|
|
|
|
|
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<span>
|
|
|
|
|
|
{fmt(paidN)} of {fmt(billsTotalN)} paid
|
|
|
|
|
|
</span>
|
2026-06-03 21:30:02 -05:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-04 22:58:34 -05:00
|
|
|
|
{unpaidCount === 0 && Number(totalCount || 0) > 0 && (
|
2026-06-03 21:30:02 -05:00
|
|
|
|
<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',
|
2026-07-06 14:23:53 -05:00
|
|
|
|
amountPct === 100
|
|
|
|
|
|
? 'bg-emerald-500'
|
|
|
|
|
|
: isNegative
|
|
|
|
|
|
? 'bg-destructive/70'
|
|
|
|
|
|
: 'bg-primary',
|
2026-06-03 21:30:02 -05:00
|
|
|
|
)}
|
|
|
|
|
|
style={{ width: `${amountPct}%` }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
2026-07-04 22:58:34 -05:00
|
|
|
|
{fmt(starting)} starting · {fmt(billsTotalN)} due
|
2026-06-03 21:30:02 -05:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function CashFlowCard({
|
|
|
|
|
|
cashflow,
|
|
|
|
|
|
year,
|
|
|
|
|
|
month,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
cashflow?: Cashflow;
|
|
|
|
|
|
year: number;
|
|
|
|
|
|
month: number;
|
|
|
|
|
|
}) {
|
2026-06-03 21:30:02 -05:00
|
|
|
|
if (!cashflow?.has_data) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const periodProjected = Number(cashflow.period_projected ?? 0);
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const monthProjected = Number(cashflow.month_projected ?? 0);
|
2026-06-03 21:30:02 -05:00
|
|
|
|
|
|
|
|
|
|
// 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>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
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.
|
2026-06-03 21:30:02 -05:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:58:34 -05:00
|
|
|
|
function DebtPayoffGlance({ projection }: { projection: SnowballProjection | null }) {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const snowball = projection?.snowball;
|
|
|
|
|
|
const comparison = projection?.comparison;
|
2026-05-18 09:44:16 -05:00
|
|
|
|
const targetDebt = snowball?.debts?.[0] || null;
|
|
|
|
|
|
const targetMonths = Number(targetDebt?.months || 0);
|
|
|
|
|
|
const monthsSaved = comparison?.months_saved;
|
2026-05-16 20:26:09 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-18 09:44:16 -05:00
|
|
|
|
<Card className="overflow-hidden">
|
2026-05-16 20:26:09 -05:00
|
|
|
|
<CardHeader className="pb-3">
|
2026-05-18 09:44:16 -05:00
|
|
|
|
<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>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<Badge
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300"
|
|
|
|
|
|
>
|
2026-05-18 09:44:16 -05:00
|
|
|
|
Focus
|
|
|
|
|
|
</Badge>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Current payoff focus, with the final debt-free date close by.
|
|
|
|
|
|
</CardDescription>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
{snowball?.months_to_freedom ? (
|
|
|
|
|
|
<div className="space-y-3">
|
2026-05-18 09:44:16 -05:00
|
|
|
|
{targetDebt && (
|
2026-07-10 18:03:15 -05:00
|
|
|
|
<div className="rounded-xl border border-emerald-500/25 bg-emerald-500/8 p-3">
|
2026-05-18 09:44:16 -05:00
|
|
|
|
<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>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="mt-1 truncate text-lg font-semibold tracking-tight">
|
|
|
|
|
|
{targetDebt.name}
|
|
|
|
|
|
</p>
|
2026-05-18 09:44:16 -05:00
|
|
|
|
<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>
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<p className="tracker-number font-semibold text-emerald-600 dark:text-emerald-300">
|
2026-05-18 09:44:16 -05:00
|
|
|
|
{targetMonths ? `${targetMonths} mo` : '—'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-07-10 18:03:15 -05:00
|
|
|
|
<div className="rounded-lg border border-sky-500/15 bg-sky-500/8 p-3">
|
2026-05-18 09:44:16 -05:00
|
|
|
|
<p className="text-xs text-muted-foreground">Debt-free</p>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="font-semibold text-sky-600 dark:text-sky-300">
|
|
|
|
|
|
{snowball.payoff_display}
|
|
|
|
|
|
</p>
|
2026-05-18 09:44:16 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
2026-05-18 09:44:16 -05:00
|
|
|
|
<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>
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<p className="tracker-number mt-2 font-semibold text-amber-600 dark:text-amber-300">
|
2026-05-18 09:44:16 -05:00
|
|
|
|
{monthsSaved !== undefined ? `${monthsSaved} mo` : '—'}
|
|
|
|
|
|
</p>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</div>
|
2026-05-18 09:44:16 -05:00
|
|
|
|
<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>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="tracker-number mt-2 font-semibold">
|
|
|
|
|
|
{fmt(snowball.total_interest_paid)}
|
|
|
|
|
|
</p>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-18 09:44:16 -05:00
|
|
|
|
|
|
|
|
|
|
{!targetDebt && (
|
2026-07-10 18:03:15 -05:00
|
|
|
|
<div className="rounded-lg border border-sky-500/25 bg-sky-500/8 p-3">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="text-sm font-medium text-sky-700 dark:text-sky-300">
|
|
|
|
|
|
Projection ready
|
|
|
|
|
|
</p>
|
2026-05-18 09:44:16 -05:00
|
|
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
|
|
|
|
Open Snowball to review the active payoff order.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
)}
|
2026-05-18 09:44:16 -05:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<p className="tracker-number font-semibold">{snowball.months_to_freedom} mo</p>
|
2026-05-18 09:44:16 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-lg bg-muted/30 p-3">
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">Interest saved</p>
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<p className="tracker-number font-semibold text-emerald-600 dark:text-emerald-300">
|
2026-05-18 09:44:16 -05:00
|
|
|
|
{comparison ? fmt(comparison.interest_saved) : '—'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
<Link to="/snowball">Open Snowball</Link>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-07-10 18:03:15 -05:00
|
|
|
|
<div className="space-y-3 rounded-xl border border-sky-500/25 bg-sky-500/8 p-3">
|
2026-05-18 09:44:16 -05:00
|
|
|
|
<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>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
<Link to="/snowball">Set up Snowball</Link>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function DayDetailDialog({
|
|
|
|
|
|
day,
|
|
|
|
|
|
open,
|
|
|
|
|
|
onOpenChange,
|
|
|
|
|
|
moneyMarker,
|
|
|
|
|
|
}: {
|
2026-07-04 22:58:34 -05:00
|
|
|
|
day: CalDay | null;
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
|
moneyMarker: MoneyMarker | null;
|
|
|
|
|
|
}) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
|
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
|
|
|
|
|
|
<DialogHeader>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
{day && (
|
|
|
|
|
|
<div className="space-y-5">
|
2026-05-16 20:26:09 -05:00
|
|
|
|
{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>
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<p className="tracker-number mt-1 text-lg font-semibold text-teal-600 dark:text-teal-300">
|
2026-05-16 20:26:09 -05:00
|
|
|
|
+{fmt(moneyMarker.amount)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">{moneyMarker.label}</p>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<section>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
|
Bills Due
|
|
|
|
|
|
</h3>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{day.bills_due.length === 0 ? (
|
2026-06-07 15:14:09 -05:00
|
|
|
|
<div className="rounded-lg bg-muted/15 p-4 text-sm text-muted-foreground">
|
|
|
|
|
|
No bills due this day.
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{day.bills_due.map((bill) => (
|
2026-05-28 23:42:46 -05:00
|
|
|
|
<div
|
|
|
|
|
|
key={bill.bill_id}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'rounded-lg border border-border/70 bg-background/75 p-3',
|
|
|
|
|
|
(bill.status === 'late' || bill.status === 'missed') &&
|
2026-07-10 18:03:15 -05:00
|
|
|
|
'border-rose-400/40 bg-rose-500/10 ring-1 ring-inset ring-rose-400/20',
|
2026-05-28 23:42:46 -05:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
|
|
<div className="min-w-0">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<Badge
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className={cn('shrink-0 capitalize', statusTone(bill.status))}
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{displayStatus(bill.status)}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<div className="mt-3 grid grid-cols-3 gap-2 text-xs font-medium text-muted-foreground">
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<div>
|
|
|
|
|
|
<p>Expected</p>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="tracker-number text-sm font-semibold text-foreground">
|
|
|
|
|
|
{fmt(bill.effective_amount)}
|
|
|
|
|
|
</p>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p>Paid</p>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="tracker-number text-sm font-semibold text-emerald-600 dark:text-emerald-300">
|
|
|
|
|
|
{fmt(bill.paid_amount)}
|
|
|
|
|
|
</p>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p>Due</p>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="tracker-number text-sm font-semibold text-foreground">
|
|
|
|
|
|
{fmtDate(bill.due_date)}
|
|
|
|
|
|
</p>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
|
Payments
|
|
|
|
|
|
</h3>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{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">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{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"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<div className="min-w-0">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<span className="tracker-number text-sm font-semibold text-emerald-600 dark:text-emerald-300">
|
|
|
|
|
|
{fmt(payment.amount)}
|
|
|
|
|
|
</span>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function CalendarSubscribeDialog({
|
|
|
|
|
|
open,
|
|
|
|
|
|
onOpenChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
|
}) {
|
2026-06-07 16:52:50 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
|
<DialogContent className="border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-3xl">
|
|
|
|
|
|
<DialogHeader>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<DialogTitle className="text-lg font-semibold tracking-tight">
|
|
|
|
|
|
Subscribe to Bill Calendar
|
|
|
|
|
|
</DialogTitle>
|
2026-06-07 16:52:50 -05:00
|
|
|
|
<DialogDescription>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
Create a private calendar feed URL, preview what will appear, then copy it into your
|
|
|
|
|
|
calendar app. Bill Tracker does not add anything to Apple, Google, Android, or Outlook
|
|
|
|
|
|
until you subscribe with that URL.
|
2026-06-07 16:52:50 -05:00
|
|
|
|
</DialogDescription>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
2026-07-10 18:03:15 -05:00
|
|
|
|
<div className="rounded-lg border border-primary/20 bg-primary/6 p-3">
|
2026-06-07 16:52:50 -05:00
|
|
|
|
<p className="text-sm font-medium text-foreground">Best for keeping calendars updated</p>
|
|
|
|
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
A subscription stays linked to Bill Tracker, so future due-date or amount changes can
|
|
|
|
|
|
appear when your calendar app refreshes the feed. Calendar apps control their own
|
|
|
|
|
|
refresh timing.
|
2026-06-07 16:52:50 -05:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<CalendarFeedManager compact showManageLink />
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
|
export default function CalendarPage() {
|
2026-07-05 14:49:09 -05:00
|
|
|
|
const [searchParams] = useSearchParams();
|
|
|
|
|
|
// Land on ?year=&month= when deep-linked (e.g. from the tracker), else today.
|
|
|
|
|
|
const initial = (() => {
|
|
|
|
|
|
const y = Number(searchParams.get('year'));
|
|
|
|
|
|
const m = Number(searchParams.get('month'));
|
|
|
|
|
|
return y && m >= 1 && m <= 12 ? { year: y, month: m } : currentMonth();
|
|
|
|
|
|
})();
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const [year, setYear] = useState(initial.year);
|
|
|
|
|
|
const [month, setMonth] = useState(initial.month);
|
2026-07-05 15:57:35 -05:00
|
|
|
|
const { data: settingsData } = useSettings();
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const weekStart =
|
|
|
|
|
|
Number((settingsData as Record<string, unknown> | undefined)?.week_start) === 1 ? 1 : 0;
|
2026-07-04 22:58:34 -05:00
|
|
|
|
const [data, setData] = useState<CalendarData | null>(null);
|
|
|
|
|
|
const [summaryData, setSummaryData] = useState<SummaryDataShape | null>(null);
|
|
|
|
|
|
const [snowballProjection, setSnowballProjection] = useState<SnowballProjection | null>(null);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState('');
|
2026-07-04 22:58:34 -05:00
|
|
|
|
const [selectedDay, setSelectedDay] = useState<CalDay | null>(null);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
2026-06-07 16:52:50 -05:00
|
|
|
|
const [calendarFeedOpen, setCalendarFeedOpen] = useState(false);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError('');
|
|
|
|
|
|
try {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const [calendarResult, summaryResult, snowballResult] = await Promise.allSettled([
|
|
|
|
|
|
api.calendar(year, month),
|
|
|
|
|
|
api.summary(year, month),
|
|
|
|
|
|
api.snowballProjection(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
if (calendarResult.status === 'rejected') {
|
|
|
|
|
|
throw calendarResult.reason;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:58:34 -05:00
|
|
|
|
const result = calendarResult.value as CalendarData;
|
2026-05-04 13:14:32 -05:00
|
|
|
|
setData(result);
|
2026-07-06 14:23:53 -05:00
|
|
|
|
setSummaryData(
|
|
|
|
|
|
summaryResult.status === 'fulfilled' ? (summaryResult.value as SummaryDataShape) : null,
|
|
|
|
|
|
);
|
|
|
|
|
|
setSnowballProjection(
|
|
|
|
|
|
snowballResult.status === 'fulfilled' ? (snowballResult.value as SnowballProjection) : null,
|
|
|
|
|
|
);
|
|
|
|
|
|
setSelectedDay((current) =>
|
|
|
|
|
|
current ? result.days.find((day) => day.date === current.date) || null : null,
|
|
|
|
|
|
);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
} catch (err) {
|
2026-07-04 22:58:34 -05:00
|
|
|
|
setError(errMessage(err, 'Calendar data could not be loaded.'));
|
|
|
|
|
|
toast.error(errMessage(err, 'Calendar data could not be loaded.'));
|
2026-05-04 13:14:32 -05:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [year, month]);
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
load();
|
|
|
|
|
|
}, [load]);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
|
|
|
|
|
const monthLabel = useMemo(() => `${MONTHS[month - 1]} ${year}`, [year, month]);
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const hasAnyBills =
|
|
|
|
|
|
Number(data?.summary?.bill_count || 0) + Number(data?.summary?.skipped_count || 0) > 0;
|
2026-07-04 22:58:34 -05:00
|
|
|
|
const moneyMarkers = useMemo<Record<string, MoneyMarker>>(() => {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const starting = summaryData?.starting_amounts;
|
|
|
|
|
|
if (!starting) return {};
|
|
|
|
|
|
|
2026-07-04 22:58:34 -05:00
|
|
|
|
const markers: Record<string, MoneyMarker> = {};
|
2026-05-16 20:26:09 -05:00
|
|
|
|
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;
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
2026-07-04 22:58:34 -05:00
|
|
|
|
function navigate(delta: number) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
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">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
className="h-8 w-8 rounded-full"
|
|
|
|
|
|
onClick={() => navigate(-1)}
|
|
|
|
|
|
aria-label="Previous month"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<ChevronLeft className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
2026-07-10 18:03:15 -05:00
|
|
|
|
<span className="min-w-32 px-1 text-center text-sm font-semibold tabular-nums select-none">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{monthLabel}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
className="h-8 w-8 rounded-full"
|
|
|
|
|
|
onClick={() => navigate(1)}
|
|
|
|
|
|
aria-label="Next month"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<Button variant="outline" size="sm" onClick={goToday}>
|
|
|
|
|
|
Today
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="gap-2"
|
|
|
|
|
|
onClick={() => setCalendarFeedOpen(true)}
|
|
|
|
|
|
>
|
2026-06-07 16:52:50 -05:00
|
|
|
|
<CalendarDays className="h-4 w-4" />
|
|
|
|
|
|
Subscribe Calendar
|
2026-06-07 15:53:46 -05:00
|
|
|
|
</Button>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
className="h-9 w-9 rounded-full"
|
|
|
|
|
|
onClick={load}
|
|
|
|
|
|
aria-label="Refresh calendar"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
|
<MoneyMap summaryData={summaryData} loading={loading} />
|
|
|
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<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" />
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<LegendItem className="border-destructive bg-destructive" label="Missed/Late" />
|
2026-05-28 21:40:27 -05:00
|
|
|
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-foreground/75">
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<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>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<Button variant="outline" size="sm" onClick={load}>
|
|
|
|
|
|
Try again
|
|
|
|
|
|
</Button>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && !error && data && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<CalendarGrid
|
|
|
|
|
|
data={data}
|
2026-07-05 15:57:35 -05:00
|
|
|
|
weekStart={weekStart}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
selectedDate={selectedDay?.date}
|
2026-05-16 20:26:09 -05:00
|
|
|
|
moneyMarkers={moneyMarkers}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
onSelectDay={(day) => {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
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>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
|
|
|
|
Add a bill to start seeing due dates and payment progress.
|
|
|
|
|
|
</p>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<Button asChild size="sm">
|
|
|
|
|
|
<Link to="/bills">Add bill</Link>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<SummaryProgress summary={data?.summary} />
|
2026-06-03 21:30:02 -05:00
|
|
|
|
<CashFlowCard cashflow={data?.cashflow} year={year} month={month} />
|
2026-05-16 20:26:09 -05:00
|
|
|
|
<DebtPayoffGlance projection={snowballProjection} />
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<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>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="tracker-number font-semibold">
|
|
|
|
|
|
{fmt(selectedDay.status_summary.total_due)}
|
|
|
|
|
|
</p>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="rounded-lg bg-muted/40 p-3">
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">Paid</p>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="tracker-number font-semibold text-emerald-600 dark:text-emerald-300">
|
|
|
|
|
|
{fmt(selectedDay.status_summary.total_paid)}
|
|
|
|
|
|
</p>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</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}
|
2026-05-16 20:26:09 -05:00
|
|
|
|
moneyMarker={selectedMoneyMarker}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
open={detailOpen}
|
|
|
|
|
|
onOpenChange={setDetailOpen}
|
|
|
|
|
|
/>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<CalendarSubscribeDialog open={calendarFeedOpen} onOpenChange={setCalendarFeedOpen} />
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|