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 (
Skipped
);
}
if (expense.is_paid) {
return (
Paid
);
}
return (
Open
);
}
function SummaryChart({ rows = [] }) {
const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
const chartRows = rows.map((row, index) => ({
...row,
label: row.type === 'Remaining'
? Number(row.amount) >= 0 ? 'Remaining' : 'Shortfall'
: 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 (
{chartRows.map(row => (
{row.label}
{fmt(row.amount)}
))}
);
}
function ExpenseRow({ expense }) {
return (
{expense.name}
{expense.category_name && {expense.category_name} }
Due day {expense.due_day}
{expense.actual_amount !== null && Monthly amount }
{fmt(expense.display_amount)}
);
}
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('');
const [startingFirst, setStartingFirst] = useState('0');
const [startingFifteenth, setStartingFifteenth] = useState('0');
const [startingOther, setStartingOther] = useState('0');
const [editingStarting, setEditingStarting] = useState(false);
const loadSummary = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await api.summary(selected.year, selected.month);
setData(result);
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);
} 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 || [];
const starting = data?.starting_amounts || {};
const generatedLabel = useMemo(() => {
if (!data?.generated_at) return '';
return new Date(data.generated_at).toLocaleString();
}, [data?.generated_at]);
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.');
return;
}
setSaving(true);
try {
await api.updateMonthlyStartingAmounts({
year: selected.year,
month: selected.month,
first_amount: first,
fifteenth_amount: fifteenth,
other_amount: other,
});
toast.success('Starting amounts saved.');
await loadSummary();
} catch (err) {
toast.error(err.message || 'Starting amounts could not be saved.');
} finally {
setSaving(false);
}
}
function moveMonth(delta) {
setSelected(current => shiftMonth(current.year, current.month, delta));
}
function resetToday() {
setSelected(selectedFromToday());
}
return (
BillTracker Summary
{monthLabel(selected.year, selected.month)}
{generatedLabel &&
Generated {generatedLabel}
}
Summary
Plan starting balance, expenses, and monthly result.
Today
window.print()} className="sm:w-auto">
Print / PDF
moveMonth(-1)} aria-label="Previous month">
{monthLabel(selected.year, selected.month)}
moveMonth(1)} aria-label="Next month">
{loading && (
Loading summary...
)}
{!loading && error && (
{error}
Retry
)}
{!loading && !error && data && (
<>
Monthly Plan
{monthLabel(data.year, data.month)}
Starting Balance
setEditingStarting(value => !value)}
>
{editingStarting ? 'Close' : 'Edit'}
1st
{fmt(starting.first_amount)}
15th
{fmt(starting.fifteenth_amount)}
Other
{fmt(starting.other_amount)}
Total starting
{fmt(starting.combined_amount)}
Paid
{fmt(starting.paid_total)}
Total remaining
{fmt(starting.combined_remaining)}
{data.previous_month && (
Previous month remaining: {fmt(data.previous_month.combined_remaining)}
)}
{editingStarting && (
1st
setStartingFirst(event.target.value)}
/>
15th
setStartingFifteenth(event.target.value)}
/>
Other
setStartingOther(event.target.value)}
/>
{saving ? : }
Save
)}
Expenses
Skipped bills are shown but not counted.
Paid
{expenses.length === 0 ? (
No bills found for this month.
) : (
{expenses.map(expense => (
))}
)}
Fully Paid Expenses
{summary.paid_expense_count || 0} / {summary.expense_count || 0}
Expenses
{fmt(summary.expense_total)}
Result
{fmt(summary.result)}
window.print()} className="summary-actions w-full">
Print / PDF
Total amount per type
Starting balance, planned expenses, and {Number(summary.result || 0) >= 0 ? 'remaining' : 'shortfall'} for {monthLabel(data.year, data.month)}.
Generated {generatedLabel || 'now'}
>
)}
);
}