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}
)}
{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 [incomeAmount, setIncomeAmount] = useState('0'); const [incomeLabel, setIncomeLabel] = useState('Salary'); const [editingIncome, setEditingIncome] = 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); setIncomeAmount(String(result.income?.amount ?? 0)); setIncomeLabel(result.income?.label || 'Salary'); setEditingIncome(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); } } async function saveIncome() { const amount = Number(incomeAmount); if (!Number.isFinite(amount) || amount < 0) { toast.error('Enter a valid income amount.'); return; } const label = incomeLabel.trim() || 'Salary'; setSaving(true); try { await api.saveSummaryIncome({ year: selected.year, month: selected.month, amount, label }); toast.success('Income saved.'); setEditingIncome(false); 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()); } 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.

{monthLabel(selected.year, selected.month)}
{loading && ( Loading summary... )} {!loading && error && (

{error}

)} {!loading && !error && data && ( <> Monthly Plan {monthLabel(data.year, data.month)}

Starting Balance

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 && (
)}

Monthly Income

{data?.income?.label || 'Salary'}
{fmt(data?.income?.amount ?? 0)}
{Number(data?.income?.amount) > 0 && Number(summary?.expense_total) > 0 && (
After expenses
{fmt(Number(data.income.amount) - Number(summary.expense_total))}
)}
{editingIncome && (
)}

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)}
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'}
)}
); }