import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import {
ArrowDown,
ArrowUp,
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Edit3,
GripVertical,
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';
import { moveInArray, reorderPayload } from '@/lib/reorder';
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 = [], onStartingClick }) {
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 => {
const isStarting = row.type === 'Starting';
const clickable = isStarting && !!onStartingClick;
return (
{clickable ? (
{row.label}
) : (
{row.label}
)}
{fmt(row.amount)}
);
})}
);
}
function ExpenseRow({ expense, moveControls, dragProps }) {
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 [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
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);
setDraggingId(null);
setDropTargetId(null);
setMovingBillId(null);
} 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 reorderEnabled = !loading && !error && expenses.length > 1;
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());
}
async function persistExpenseOrder(nextExpenses, movedId) {
setData(prev => prev ? { ...prev, expenses: nextExpenses } : prev);
setMovingBillId(movedId);
try {
await api.reorderBills(reorderPayload(nextExpenses.map(expense => ({ id: expense.bill_id }))));
toast.success('Summary order saved');
await loadSummary();
} catch (err) {
toast.error(err.message || 'Failed to save summary order.');
await loadSummary();
} finally {
setMovingBillId(null);
}
}
function reorderExpenses(fromIndex, toIndex) {
if (!reorderEnabled || fromIndex === toIndex) return;
const nextExpenses = moveInArray(expenses, fromIndex, toIndex);
persistExpenseOrder(nextExpenses, expenses[fromIndex]?.bill_id || null);
}
function moveControlsFor(expense, index) {
return {
enabled: reorderEnabled,
moving: movingBillId === expense.bill_id,
canMoveUp: index > 0,
canMoveDown: index < expenses.length - 1,
onMoveUp: () => reorderExpenses(index, index - 1),
onMoveDown: () => reorderExpenses(index, index + 1),
};
}
function dragPropsFor(expense, index) {
if (!reorderEnabled) return { draggable: false };
return {
draggable: true,
isDragging: draggingId === expense.bill_id,
isDropTarget: dropTargetId === expense.bill_id && draggingId !== expense.bill_id,
onDragStart: (event) => {
setDraggingId(expense.bill_id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(expense.bill_id));
},
onDragEnter: () => {
if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id);
},
onDragOver: (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id);
},
onDrop: (event) => {
event.preventDefault();
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
const fromIndex = expenses.findIndex(item => item.bill_id === sourceId);
if (fromIndex >= 0) reorderExpenses(fromIndex, index);
setDraggingId(null);
setDropTargetId(null);
},
onDragEnd: () => {
setDraggingId(null);
setDropTargetId(null);
},
};
}
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, index) => (
))}
)}
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'}
>
)}
);
}