refactor(ts): CalendarPage → TypeScript (money map + grid + cashflow + snowball glance)
This commit is contained in:
parent
82bd13036a
commit
dbf3c42d62
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Banknote,
|
Banknote,
|
||||||
|
|
@ -15,7 +15,8 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
import { cn, fmtDate, todayStr, errMessage } from '@/lib/utils';
|
||||||
|
import { formatUSD, asDollars } from '@/lib/money';
|
||||||
import { isPaidStatus } from '@/lib/trackerUtils';
|
import { isPaidStatus } from '@/lib/trackerUtils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
@ -23,6 +24,123 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { CalendarFeedManager } from '@/components/CalendarFeedManager';
|
import { CalendarFeedManager } from '@/components/CalendarFeedManager';
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnowballDebt { name?: string; months?: number; payoff_display?: string }
|
||||||
|
interface SnowballProjection {
|
||||||
|
snowball?: {
|
||||||
|
debts?: SnowballDebt[];
|
||||||
|
months_to_freedom?: number;
|
||||||
|
payoff_display?: string;
|
||||||
|
total_interest_paid?: number;
|
||||||
|
};
|
||||||
|
comparison?: { months_saved?: number; interest_saved?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MoneyMarker { label: string; amount: number }
|
||||||
|
|
||||||
const MONTHS = [
|
const MONTHS = [
|
||||||
'January', 'February', 'March', 'April', 'May', 'June',
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
'July', 'August', 'September', 'October', 'November', 'December',
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
|
@ -34,26 +152,26 @@ function currentMonth() {
|
||||||
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function shiftMonth(year, month, delta) {
|
function shiftMonth(year: number, month: number, delta: number) {
|
||||||
const next = new Date(year, month - 1 + delta, 1);
|
const next = new Date(year, month - 1 + delta, 1);
|
||||||
return { year: next.getFullYear(), month: next.getMonth() + 1 };
|
return { year: next.getFullYear(), month: next.getMonth() + 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayStatus(status) {
|
function displayStatus(status?: string): string {
|
||||||
if (status === 'due_soon') return 'Due';
|
if (status === 'due_soon') return 'Due';
|
||||||
if (status === 'late') return 'Late';
|
if (status === 'late') return 'Late';
|
||||||
return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Due';
|
return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Due';
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusTone(status) {
|
function statusTone(status?: string): string {
|
||||||
if (isPaidStatus(status)) return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300';
|
if (isPaidStatus(status || '')) 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 === '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 === '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';
|
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';
|
return 'border-primary/30 bg-primary/15 text-primary';
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegendItem({ className, label }) {
|
function LegendItem({ className, label }: { className?: string; label: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-foreground/75">
|
<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)} />
|
<span className={cn('h-2.5 w-2.5 rounded-full border', className)} />
|
||||||
|
|
@ -62,7 +180,13 @@ function LegendItem({ className, label }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }) {
|
function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }: {
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
label: ReactNode;
|
||||||
|
value: number | null | undefined;
|
||||||
|
hint?: ReactNode;
|
||||||
|
valueClassName?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 rounded-lg border border-border/70 bg-background/70 p-3">
|
<div className="min-w-0 rounded-lg border border-border/70 bg-background/70 p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -77,7 +201,7 @@ function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MoneyMap({ summaryData, loading }) {
|
function MoneyMap({ summaryData, loading }: { summaryData: SummaryDataShape | null; loading: boolean }) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -94,7 +218,7 @@ function MoneyMap({ summaryData, loading }) {
|
||||||
const summary = summaryData?.summary || {};
|
const summary = summaryData?.summary || {};
|
||||||
const bt = summaryData?.bank_tracking;
|
const bt = summaryData?.bank_tracking;
|
||||||
const bankMode = bt?.enabled === true;
|
const bankMode = bt?.enabled === true;
|
||||||
const available = bankMode ? Number(bt.effective_balance || 0) : Number(starting.combined_amount || 0);
|
const available = bankMode ? Number(bt?.effective_balance || 0) : Number(starting.combined_amount || 0);
|
||||||
const assigned = Number(summary.expense_total || 0);
|
const assigned = Number(summary.expense_total || 0);
|
||||||
const paid = Number(summary.paid_total || 0);
|
const paid = Number(summary.paid_total || 0);
|
||||||
const remaining = Number(summary.result || 0);
|
const remaining = Number(summary.result || 0);
|
||||||
|
|
@ -108,7 +232,7 @@ function MoneyMap({ summaryData, loading }) {
|
||||||
<CardTitle className="text-base">Monthly Money Map</CardTitle>
|
<CardTitle className="text-base">Monthly Money Map</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{bankMode
|
{bankMode
|
||||||
? `Live bank balance · ${bt.account_name}`
|
? `Live bank balance · ${bt?.account_name}`
|
||||||
: 'Available money, extra income, assigned bills, and what remains.'}
|
: 'Available money, extra income, assigned bills, and what remains.'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -126,15 +250,15 @@ function MoneyMap({ summaryData, loading }) {
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
{bankMode ? (
|
{bankMode ? (
|
||||||
<>
|
<>
|
||||||
<MoneyMetric icon={Banknote} label="Bank Balance" value={Number(bt.balance || 0)} hint={`as of last sync`} />
|
<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={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={CalendarDays} label="Unpaid Bills" value={Number(bt?.unpaid_this_month || 0)} hint={`${summary.expense_count || 0} active bills`} />
|
||||||
<MoneyMetric
|
<MoneyMetric
|
||||||
icon={CircleDollarSign}
|
icon={CircleDollarSign}
|
||||||
label="After Bills"
|
label="After Bills"
|
||||||
value={Number(bt.remaining || 0)}
|
value={Number(bt?.remaining || 0)}
|
||||||
hint="effective balance − unpaid"
|
hint="effective balance − unpaid"
|
||||||
valueClassName={Number(bt.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
|
valueClassName={Number(bt?.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -173,7 +297,7 @@ function MoneyMap({ summaryData, loading }) {
|
||||||
{bankMode && (
|
{bankMode && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex items-center justify-between gap-3 rounded-xl border px-4 py-3',
|
'flex items-center justify-between gap-3 rounded-xl border px-4 py-3',
|
||||||
Number(bt.remaining || 0) >= 0
|
Number(bt?.remaining || 0) >= 0
|
||||||
? 'border-emerald-500/25 bg-emerald-500/5'
|
? 'border-emerald-500/25 bg-emerald-500/5'
|
||||||
: 'border-destructive/25 bg-destructive/5',
|
: 'border-destructive/25 bg-destructive/5',
|
||||||
)}>
|
)}>
|
||||||
|
|
@ -182,21 +306,21 @@ function MoneyMap({ summaryData, loading }) {
|
||||||
Projected Month-End Balance
|
Projected Month-End Balance
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-muted-foreground/70">
|
<p className="text-[11px] text-muted-foreground/70">
|
||||||
{fmt(bt.balance || 0)} bank
|
{fmt(bt?.balance || 0)} bank
|
||||||
{Number(bt.pending_payments || 0) > 0 && ` − ${fmt(bt.pending_payments)} pending`}
|
{Number(bt?.pending_payments || 0) > 0 && ` − ${fmt(bt?.pending_payments)} pending`}
|
||||||
{` − ${fmt(bt.unpaid_this_month || 0)} remaining bills`}
|
{` − ${fmt(bt?.unpaid_this_month || 0)} remaining bills`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
'tracker-number text-2xl font-bold tabular-nums shrink-0',
|
'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 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive',
|
||||||
)}>
|
)}>
|
||||||
{Number(bt.remaining || 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bt.remaining || 0)))}
|
{Number(bt?.remaining || 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bt?.remaining || 0)))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{bankMode && bt.last_updated && (
|
{bankMode && bt?.last_updated && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Balance last updated: {new Date(bt.last_updated).toLocaleString()}
|
Balance last updated: {new Date(bt.last_updated).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -206,7 +330,7 @@ function MoneyMap({ summaryData, loading }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SummaryProgress({ summary }) {
|
function SummaryProgress({ summary }: { summary?: SummaryInfo }) {
|
||||||
const percent = Number(summary?.paid_percent || 0);
|
const percent = Number(summary?.paid_percent || 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -252,7 +376,7 @@ function SummaryProgress({ summary }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DayIndicators({ day, moneyMarker }) {
|
function DayIndicators({ day, moneyMarker }: { day: CalDay; moneyMarker: MoneyMarker | null }) {
|
||||||
const summary = day.status_summary;
|
const summary = day.status_summary;
|
||||||
const hasPaid = summary.paid_count > 0;
|
const hasPaid = summary.paid_count > 0;
|
||||||
const hasDue = summary.due_count > summary.paid_count + summary.skipped_count + summary.missed_count;
|
const hasDue = summary.due_count > summary.paid_count + summary.skipped_count + summary.missed_count;
|
||||||
|
|
@ -271,11 +395,16 @@ function DayIndicators({ day, moneyMarker }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) {
|
function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }: {
|
||||||
|
data: CalendarData;
|
||||||
|
selectedDate?: string;
|
||||||
|
onSelectDay: (day: CalDay) => void;
|
||||||
|
moneyMarkers?: Record<string, MoneyMarker>;
|
||||||
|
}) {
|
||||||
const firstWeekday = new Date(data.year, data.month - 1, 1).getDay();
|
const firstWeekday = new Date(data.year, data.month - 1, 1).getDay();
|
||||||
const cells = [
|
const cells: ({ type: 'blank'; key: string } | { type: 'day'; key: string; day: CalDay })[] = [
|
||||||
...Array.from({ length: firstWeekday }, (_, index) => ({ type: 'blank', key: `blank-${index}` })),
|
...Array.from({ length: firstWeekday }, (_, index) => ({ type: 'blank' as const, key: `blank-${index}` })),
|
||||||
...data.days.map(day => ({ type: 'day', key: day.date, day })),
|
...data.days.map(day => ({ type: 'day' as const, key: day.date, day })),
|
||||||
];
|
];
|
||||||
const today = todayStr();
|
const today = todayStr();
|
||||||
|
|
||||||
|
|
@ -363,11 +492,24 @@ function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCount, totalCount, period, year, month }) {
|
function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCount, totalCount, period, year, month }: {
|
||||||
|
label: ReactNode;
|
||||||
|
projected: number;
|
||||||
|
starting?: number;
|
||||||
|
billsTotal?: number;
|
||||||
|
paid?: number;
|
||||||
|
paidCount?: number;
|
||||||
|
totalCount?: number;
|
||||||
|
period?: string;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const billsTotalN = Number(billsTotal || 0);
|
||||||
|
const paidN = Number(paid || 0);
|
||||||
const isNegative = projected < 0;
|
const isNegative = projected < 0;
|
||||||
const amountPct = billsTotal > 0 ? Math.min(100, Math.round((paid / billsTotal) * 100)) : 0;
|
const amountPct = billsTotalN > 0 ? Math.min(100, Math.round((paidN / billsTotalN) * 100)) : 0;
|
||||||
const unpaidCount = totalCount - paidCount;
|
const unpaidCount = Number(totalCount || 0) - Number(paidCount || 0);
|
||||||
const bucketParam = period === '1st' ? 'b1=1' : 'b2=1';
|
const bucketParam = period === '1st' ? 'b1=1' : 'b2=1';
|
||||||
|
|
||||||
function goToUnpaid() {
|
function goToUnpaid() {
|
||||||
|
|
@ -393,7 +535,7 @@ function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCou
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{/* Amount-based progress bar */}
|
{/* Amount-based progress bar */}
|
||||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||||
<span>{fmt(paid)} of {fmt(billsTotal)} paid</span>
|
<span>{fmt(paidN)} of {fmt(billsTotalN)} paid</span>
|
||||||
{unpaidCount > 0 && (
|
{unpaidCount > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -404,7 +546,7 @@ function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCou
|
||||||
{unpaidCount} unpaid →
|
{unpaidCount} unpaid →
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{unpaidCount === 0 && totalCount > 0 && (
|
{unpaidCount === 0 && Number(totalCount || 0) > 0 && (
|
||||||
<span className="text-emerald-600 dark:text-emerald-400">All paid ✓</span>
|
<span className="text-emerald-600 dark:text-emerald-400">All paid ✓</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -418,14 +560,14 @@ function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCou
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{fmt(starting)} starting · {fmt(billsTotal)} due
|
{fmt(starting)} starting · {fmt(billsTotalN)} due
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CashFlowCard({ cashflow, year, month }) {
|
function CashFlowCard({ cashflow, year, month }: { cashflow?: Cashflow; year: number; month: number }) {
|
||||||
if (!cashflow?.has_data) return null;
|
if (!cashflow?.has_data) return null;
|
||||||
|
|
||||||
const periodProjected = Number(cashflow.period_projected ?? 0);
|
const periodProjected = Number(cashflow.period_projected ?? 0);
|
||||||
|
|
@ -503,7 +645,7 @@ function CashFlowCard({ cashflow, year, month }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DebtPayoffGlance({ projection }) {
|
function DebtPayoffGlance({ projection }: { projection: SnowballProjection | null }) {
|
||||||
const snowball = projection?.snowball;
|
const snowball = projection?.snowball;
|
||||||
const comparison = projection?.comparison;
|
const comparison = projection?.comparison;
|
||||||
const targetDebt = snowball?.debts?.[0] || null;
|
const targetDebt = snowball?.debts?.[0] || null;
|
||||||
|
|
@ -626,7 +768,12 @@ function DebtPayoffGlance({ projection }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) {
|
function DayDetailDialog({ day, open, onOpenChange, moneyMarker }: {
|
||||||
|
day: CalDay | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
moneyMarker: MoneyMarker | null;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
|
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
|
||||||
|
|
@ -730,7 +877,7 @@ function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarSubscribeDialog({ open, onOpenChange }) {
|
function CalendarSubscribeDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-3xl">
|
<DialogContent className="border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-3xl">
|
||||||
|
|
@ -758,12 +905,12 @@ export default function CalendarPage() {
|
||||||
const initial = currentMonth();
|
const initial = currentMonth();
|
||||||
const [year, setYear] = useState(initial.year);
|
const [year, setYear] = useState(initial.year);
|
||||||
const [month, setMonth] = useState(initial.month);
|
const [month, setMonth] = useState(initial.month);
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState<CalendarData | null>(null);
|
||||||
const [summaryData, setSummaryData] = useState(null);
|
const [summaryData, setSummaryData] = useState<SummaryDataShape | null>(null);
|
||||||
const [snowballProjection, setSnowballProjection] = useState(null);
|
const [snowballProjection, setSnowballProjection] = useState<SnowballProjection | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [selectedDay, setSelectedDay] = useState(null);
|
const [selectedDay, setSelectedDay] = useState<CalDay | null>(null);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [calendarFeedOpen, setCalendarFeedOpen] = useState(false);
|
const [calendarFeedOpen, setCalendarFeedOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -781,14 +928,14 @@ export default function CalendarPage() {
|
||||||
throw calendarResult.reason;
|
throw calendarResult.reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = calendarResult.value;
|
const result = calendarResult.value as CalendarData;
|
||||||
setData(result);
|
setData(result);
|
||||||
setSummaryData(summaryResult.status === 'fulfilled' ? summaryResult.value : null);
|
setSummaryData(summaryResult.status === 'fulfilled' ? summaryResult.value as SummaryDataShape : null);
|
||||||
setSnowballProjection(snowballResult.status === 'fulfilled' ? snowballResult.value : null);
|
setSnowballProjection(snowballResult.status === 'fulfilled' ? snowballResult.value as SnowballProjection : null);
|
||||||
setSelectedDay(current => current ? result.days.find(day => day.date === current.date) || null : null);
|
setSelectedDay(current => current ? result.days.find(day => day.date === current.date) || null : null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Calendar data could not be loaded.');
|
setError(errMessage(err, 'Calendar data could not be loaded.'));
|
||||||
toast.error(err.message || 'Calendar data could not be loaded.');
|
toast.error(errMessage(err, 'Calendar data could not be loaded.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -798,11 +945,11 @@ export default function CalendarPage() {
|
||||||
|
|
||||||
const monthLabel = useMemo(() => `${MONTHS[month - 1]} ${year}`, [year, month]);
|
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 hasAnyBills = Number(data?.summary?.bill_count || 0) + Number(data?.summary?.skipped_count || 0) > 0;
|
||||||
const moneyMarkers = useMemo(() => {
|
const moneyMarkers = useMemo<Record<string, MoneyMarker>>(() => {
|
||||||
const starting = summaryData?.starting_amounts;
|
const starting = summaryData?.starting_amounts;
|
||||||
if (!starting) return {};
|
if (!starting) return {};
|
||||||
|
|
||||||
const markers = {};
|
const markers: Record<string, MoneyMarker> = {};
|
||||||
const firstAmount = Number(starting.first_amount || 0);
|
const firstAmount = Number(starting.first_amount || 0);
|
||||||
const fifteenthAmount = Number(starting.fifteenth_amount || 0);
|
const fifteenthAmount = Number(starting.fifteenth_amount || 0);
|
||||||
|
|
||||||
|
|
@ -823,7 +970,7 @@ export default function CalendarPage() {
|
||||||
}, [month, summaryData, year]);
|
}, [month, summaryData, year]);
|
||||||
const selectedMoneyMarker = selectedDay ? moneyMarkers[selectedDay.date] || null : null;
|
const selectedMoneyMarker = selectedDay ? moneyMarkers[selectedDay.date] || null : null;
|
||||||
|
|
||||||
function navigate(delta) {
|
function navigate(delta: number) {
|
||||||
const next = shiftMonth(year, month, delta);
|
const next = shiftMonth(year, month, delta);
|
||||||
setYear(next.year);
|
setYear(next.year);
|
||||||
setMonth(next.month);
|
setMonth(next.month);
|
||||||
Loading…
Reference in New Issue