BillTracker/client/pages/SummaryPage.jsx

439 lines
18 KiB
React
Raw Normal View History

2026-05-04 16:38:03 -05:00
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import {
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Edit3,
Loader2,
Minus,
Printer,
RotateCcw,
Save,
} from 'lucide-react';
import { api } from '@/api.js';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { cn, fmt } from '@/lib/utils';
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
function selectedFromToday() {
const now = new Date();
return { year: now.getFullYear(), month: now.getMonth() + 1 };
}
function shiftMonth(year, month, delta) {
const next = new Date(year, month - 1 + delta, 1);
return { year: next.getFullYear(), month: next.getMonth() + 1 };
}
function monthLabel(year, month) {
return `${MONTHS[month - 1]} ${year}`;
}
function moneyClass(value) {
return value >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive';
}
function StatusMark({ expense }) {
if (expense.is_skipped) {
return (
<span className="inline-flex min-w-16 items-center justify-center rounded-full bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
Skipped
</span>
);
}
if (expense.is_paid) {
return (
<span className="inline-flex min-w-16 items-center justify-center gap-1 rounded-full bg-emerald-500/10 px-2 py-1 text-xs font-semibold text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-3.5 w-3.5" />
Paid
</span>
);
}
return (
<span className="inline-flex min-w-16 items-center justify-center gap-1 rounded-full bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
<Minus className="h-3.5 w-3.5" />
Open
</span>
);
}
function SummaryChart({ rows = [] }) {
const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
const chartRows = rows.map((row, index) => ({
...row,
2026-05-04 20:12:57 -05:00
label: row.type === 'Remaining'
? Number(row.amount) >= 0 ? 'Remaining' : 'Shortfall'
2026-05-04 16:38:03 -05:00
: row.type,
color: index === 0
? 'hsl(var(--chart-1))'
: index === 1
? 'hsl(var(--chart-3))'
: Number(row.amount) >= 0
? 'hsl(var(--chart-2))'
: 'hsl(var(--destructive))',
width: Math.max(2, (Math.abs(Number(row.amount) || 0) / max) * 100),
}));
return (
<div className="space-y-3">
{chartRows.map(row => (
<div key={row.type} className="grid gap-2 sm:grid-cols-[5.75rem_minmax(0,1fr)_7rem] sm:items-center">
<div className="text-sm font-medium text-foreground">{row.label}</div>
<div className="h-7 rounded-full bg-muted/70 p-1">
<div
className="h-full rounded-full transition-[width]"
style={{ width: `${Math.min(row.width, 100)}%`, backgroundColor: row.color }}
title={`${row.label}: ${fmt(row.amount)}`}
/>
</div>
2026-05-04 20:12:57 -05:00
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Remaining' ? moneyClass(row.amount) : 'text-foreground')}>
2026-05-04 16:38:03 -05:00
{fmt(row.amount)}
</div>
</div>
))}
</div>
);
}
function ExpenseRow({ expense }) {
return (
<div className="grid gap-2 border-b border-border/60 px-1 py-3 last:border-0 sm:grid-cols-[minmax(0,1fr)_7.5rem_5.5rem] sm:items-center">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">{expense.name}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{expense.category_name && <span>{expense.category_name}</span>}
<span>Due day {expense.due_day}</span>
{expense.actual_amount !== null && <span>Monthly amount</span>}
</div>
</div>
<div className="text-sm font-semibold text-foreground sm:text-right">{fmt(expense.display_amount)}</div>
<div className="sm:justify-self-end" aria-label={expense.is_paid ? 'Paid' : expense.is_skipped ? 'Skipped' : 'Open'}>
<StatusMark expense={expense} />
</div>
</div>
);
}
export default function SummaryPage() {
const [selected, setSelected] = useState(selectedFromToday);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
2026-05-04 20:12:57 -05:00
const [startingFirst, setStartingFirst] = useState('0');
const [startingFifteenth, setStartingFifteenth] = useState('0');
const [startingOther, setStartingOther] = useState('0');
const [editingStarting, setEditingStarting] = useState(false);
2026-05-04 16:38:03 -05:00
const loadSummary = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await api.summary(selected.year, selected.month);
setData(result);
2026-05-04 20:12:57 -05:00
setStartingFirst(String(result.starting_amounts?.first_amount ?? 0));
setStartingFifteenth(String(result.starting_amounts?.fifteenth_amount ?? 0));
setStartingOther(String(result.starting_amounts?.other_amount ?? 0));
setEditingStarting(false);
2026-05-04 16:38:03 -05:00
} catch (err) {
setError(err.message || 'Summary could not be loaded.');
toast.error(err.message || 'Summary could not be loaded.');
} finally {
setLoading(false);
}
}, [selected.month, selected.year]);
useEffect(() => {
loadSummary();
}, [loadSummary]);
const summary = data?.summary || {};
const expenses = data?.expenses || [];
2026-05-04 20:12:57 -05:00
const starting = data?.starting_amounts || {};
2026-05-04 16:38:03 -05:00
const generatedLabel = useMemo(() => {
if (!data?.generated_at) return '';
return new Date(data.generated_at).toLocaleString();
}, [data?.generated_at]);
2026-05-04 20:12:57 -05:00
async function saveStartingAmounts() {
const first = Number(startingFirst);
const fifteenth = Number(startingFifteenth);
const other = Number(startingOther);
if (![first, fifteenth, other].every(value => Number.isFinite(value) && value >= 0)) {
toast.error('Enter non-negative starting amounts.');
2026-05-04 16:38:03 -05:00
return;
}
setSaving(true);
try {
2026-05-04 20:12:57 -05:00
await api.updateMonthlyStartingAmounts({
2026-05-04 16:38:03 -05:00
year: selected.year,
month: selected.month,
2026-05-04 20:12:57 -05:00
first_amount: first,
fifteenth_amount: fifteenth,
other_amount: other,
2026-05-04 16:38:03 -05:00
});
2026-05-04 20:12:57 -05:00
toast.success('Starting amounts saved.');
2026-05-04 16:38:03 -05:00
await loadSummary();
} catch (err) {
2026-05-04 20:12:57 -05:00
toast.error(err.message || 'Starting amounts could not be saved.');
2026-05-04 16:38:03 -05:00
} finally {
setSaving(false);
}
}
function moveMonth(delta) {
setSelected(current => shiftMonth(current.year, current.month, delta));
}
function resetToday() {
setSelected(selectedFromToday());
}
return (
<div className="summary-page mx-auto max-w-3xl space-y-5">
<div className="summary-print-meta hidden">
<h1>BillTracker Summary</h1>
<p>{monthLabel(selected.year, selected.month)}</p>
{generatedLabel && <p>Generated {generatedLabel}</p>}
</div>
<div className="summary-screen-header flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-foreground">Summary</h1>
2026-05-04 20:12:57 -05:00
<p className="mt-1 text-sm text-muted-foreground">Plan starting balance, expenses, and monthly result.</p>
2026-05-04 16:38:03 -05:00
</div>
<div className="summary-actions flex gap-2">
<Button variant="outline" onClick={resetToday} className="sm:w-auto">
<RotateCcw className="h-4 w-4" />
Today
</Button>
<Button onClick={() => window.print()} className="sm:w-auto">
<Printer className="h-4 w-4" />
Print / PDF
</Button>
</div>
</div>
<div className="summary-controls mx-auto flex w-full max-w-md items-center justify-between gap-2 rounded-full border border-border/70 bg-card/95 p-1.5 shadow-sm">
<Button variant="ghost" size="icon" onClick={() => moveMonth(-1)} aria-label="Previous month">
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 px-2 text-center">
<CalendarDays className="hidden h-4 w-4 text-muted-foreground sm:block" />
<div className="truncate text-base font-semibold text-foreground">{monthLabel(selected.year, selected.month)}</div>
</div>
<Button variant="ghost" size="icon" onClick={() => moveMonth(1)} aria-label="Next month">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{loading && (
<Card>
<CardContent className="flex min-h-72 items-center justify-center p-8 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading summary...
</CardContent>
</Card>
)}
{!loading && error && (
<Card className="border-destructive/40">
<CardContent className="space-y-3 p-6">
<p className="text-sm font-medium text-destructive">{error}</p>
<Button variant="outline" onClick={loadSummary}>Retry</Button>
</CardContent>
</Card>
)}
{!loading && !error && data && (
<>
<Card className="summary-card">
<CardHeader className="pb-3">
<CardTitle className="text-xl">Monthly Plan</CardTitle>
<CardDescription>{monthLabel(data.year, data.month)}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
2026-05-04 20:12:57 -05:00
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Starting Balance</h2>
2026-05-04 16:38:03 -05:00
<Button
type="button"
variant="ghost"
size="sm"
className="summary-edit-actions h-7 px-2"
2026-05-04 20:12:57 -05:00
onClick={() => setEditingStarting(value => !value)}
2026-05-04 16:38:03 -05:00
>
<Edit3 className="h-3.5 w-3.5" />
2026-05-04 20:12:57 -05:00
{editingStarting ? 'Close' : 'Edit'}
2026-05-04 16:38:03 -05:00
</Button>
</div>
2026-05-04 20:12:57 -05:00
<div className="grid gap-3 rounded-2xl bg-muted/45 p-4 sm:grid-cols-3">
<div>
<div className="text-xs font-medium text-muted-foreground">1st</div>
<div className="mt-1 font-mono text-base font-bold text-foreground">{fmt(starting.first_amount)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">15th</div>
<div className="mt-1 font-mono text-base font-bold text-foreground">{fmt(starting.fifteenth_amount)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">Other</div>
<div className="mt-1 font-mono text-base font-bold text-foreground">{fmt(starting.other_amount)}</div>
</div>
<div className="border-t border-border/60 pt-3 sm:col-span-3">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<div className="text-xs font-medium text-muted-foreground">Total starting</div>
<div className="mt-1 font-mono text-lg font-bold text-foreground">{fmt(starting.combined_amount)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">Paid</div>
<div className="mt-1 font-mono text-lg font-bold text-emerald-600 dark:text-emerald-400">{fmt(starting.paid_total)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">Total remaining</div>
<div className={cn('mt-1 font-mono text-lg font-bold', moneyClass(starting.combined_remaining || 0))}>
{fmt(starting.combined_remaining)}
</div>
</div>
</div>
2026-05-04 16:38:03 -05:00
</div>
</div>
2026-05-04 20:12:57 -05:00
{data.previous_month && (
<div className="rounded-xl border border-border/60 bg-background/70 px-3 py-2 text-xs text-muted-foreground">
Previous month remaining: {fmt(data.previous_month.combined_remaining)}
</div>
)}
{editingStarting && (
<div className="summary-income-form grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[1fr_1fr_1fr_auto] md:items-end">
2026-05-04 16:38:03 -05:00
<label className="space-y-1">
2026-05-04 20:12:57 -05:00
<span className="text-xs font-medium text-muted-foreground">1st</span>
<Input
type="number"
min="0"
step="0.01"
value={startingFirst}
onChange={event => setStartingFirst(event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">15th</span>
<Input
type="number"
min="0"
step="0.01"
value={startingFifteenth}
onChange={event => setStartingFifteenth(event.target.value)}
/>
2026-05-04 16:38:03 -05:00
</label>
<label className="space-y-1">
2026-05-04 20:12:57 -05:00
<span className="text-xs font-medium text-muted-foreground">Other</span>
2026-05-04 16:38:03 -05:00
<Input
type="number"
min="0"
step="0.01"
2026-05-04 20:12:57 -05:00
value={startingOther}
onChange={event => setStartingOther(event.target.value)}
2026-05-04 16:38:03 -05:00
/>
</label>
2026-05-04 20:12:57 -05:00
<Button onClick={saveStartingAmounts} disabled={saving} className="summary-edit-actions w-full md:w-auto">
2026-05-04 16:38:03 -05:00
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</Button>
</div>
)}
</section>
<section className="space-y-3">
<div className="flex items-end justify-between gap-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Expenses</h2>
<p className="mt-1 text-xs text-muted-foreground">Skipped bills are shown but not counted.</p>
</div>
<div className="hidden text-xs font-semibold uppercase tracking-wide text-muted-foreground sm:block">
Paid
</div>
</div>
{expenses.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-6 text-sm text-muted-foreground">
No bills found for this month.
</div>
) : (
<div className="rounded-2xl border border-border/60 bg-background/70 px-3">
{expenses.map(expense => (
<ExpenseRow key={expense.bill_id} expense={expense} />
))}
</div>
)}
</section>
<section className="space-y-3 rounded-2xl border border-border/60 bg-muted/40 p-4">
<div className="flex items-center justify-between gap-4">
<div className="text-sm font-medium text-muted-foreground">Fully Paid Expenses</div>
<div className="text-base font-bold text-foreground">{summary.paid_expense_count || 0} / {summary.expense_count || 0}</div>
</div>
<div className="flex items-center justify-between gap-4">
<div className="text-sm font-medium text-muted-foreground">Expenses</div>
<div className="text-base font-semibold text-foreground">{fmt(summary.expense_total)}</div>
</div>
<div className="flex items-center justify-between gap-4 border-t border-border/60 pt-3">
<div className="text-base font-semibold text-foreground">Result</div>
<div className={cn('text-2xl font-bold', moneyClass(summary.result || 0))}>{fmt(summary.result)}</div>
</div>
</section>
<Button onClick={() => window.print()} className="summary-actions w-full">
<Printer className="h-4 w-4" />
Print / PDF
</Button>
</CardContent>
</Card>
<Card className="summary-chart-card">
<CardHeader className="pb-3">
<CardTitle className="text-xl">Total amount per type</CardTitle>
<CardDescription>
2026-05-04 20:12:57 -05:00
Starting balance, planned expenses, and {Number(summary.result || 0) >= 0 ? 'remaining' : 'shortfall'} for {monthLabel(data.year, data.month)}.
2026-05-04 16:38:03 -05:00
</CardDescription>
</CardHeader>
<CardContent>
<SummaryChart rows={data.chart || []} />
</CardContent>
</Card>
<div className="summary-print-footer hidden text-xs text-muted-foreground">
Generated {generatedLabel || 'now'}
</div>
</>
)}
</div>
);
}