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 === 'Savings'
? Number(row.amount) >= 0 ? 'Savings' : '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 [incomeLabel, setIncomeLabel] = useState('Salary');
const [incomeAmount, setIncomeAmount] = useState('0');
const [editingIncome, setEditingIncome] = useState(false);
const loadSummary = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await api.summary(selected.year, selected.month);
setData(result);
setIncomeLabel(result.income?.label || 'Salary');
setIncomeAmount(String(result.income?.amount ?? 0));
setEditingIncome(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 generatedLabel = useMemo(() => {
if (!data?.generated_at) return '';
return new Date(data.generated_at).toLocaleString();
}, [data?.generated_at]);
async function saveIncome() {
const amount = Number(incomeAmount);
if (!Number.isFinite(amount) || amount < 0) {
toast.error('Enter a valid income amount.');
return;
}
setSaving(true);
try {
await api.saveSummaryIncome({
year: selected.year,
month: selected.month,
label: incomeLabel.trim() || 'Salary',
amount,
});
toast.success('Income saved.');
await loadSummary();
} catch (err) {
toast.error(err.message || 'Income 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 income, 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)}
Income
setEditingIncome(value => !value)}
>
{editingIncome ? 'Close' : 'Edit'}
{data.income?.label || 'Salary'}
{Number(summary.income_total || 0) === 0 && (
Add income to calculate savings.
)}
{fmt(summary.income_total)}
{editingIncome && (
Label
setIncomeLabel(event.target.value)} placeholder="Salary" />
Amount
setIncomeAmount(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
Income, planned expenses, and {Number(summary.result || 0) >= 0 ? 'savings' : 'shortfall'} for {monthLabel(data.year, data.month)}.
Generated {generatedLabel || 'now'}
>
)}
);
}