/** * Shared Ledger page — tabs for Work Log, Payments, Expenses. * Left: hierarchical spreadsheet + entry form + summary stats. * Right: configurable charts. */ import React, { useMemo, useState } from 'react'; import { useAppStore } from '@/store/appStore'; import type { WorkEntry, Payment, Expense, RecurringExpense, RecurringFrequency, LedgerTile } from '@/types'; import { workEntryValue } from '@/types'; import { HierSpreadsheet } from '@/components/spreadsheet/HierSpreadsheet'; import { WorkEntryForm, PaymentForm, ExpenseForm } from '@/components/spreadsheet/EntryForm'; import { ChartSidebar } from '@/components/charts/ChartSidebar'; import { ResizableSplit } from '@/components/layout/ResizableSplit'; import { Modal, ConfirmDialog } from '@/components/common/Modal'; import { buildHierarchyForRange, buildHierarchy, aggregate } from '@/lib/stats/aggregate'; import { fmtMoney, todayISO, nowInTZ } from '@/lib/format'; function yearStart() { return `${new Date().getFullYear()}-01-01`; } /** Sort unique string values by frequency (desc), with the most-recently-used value first. */ function freqSorted(values: Array<{ value: string; createdAt: number }>): string[] { const freq = new Map(); let mruValue = ''; let mruTime = 0; for (const { value, createdAt } of values) { if (!value) continue; freq.set(value, (freq.get(value) ?? 0) + 1); if (createdAt > mruTime) { mruTime = createdAt; mruValue = value; } } const sorted = [...freq.keys()].sort((a, b) => (freq.get(b) ?? 0) - (freq.get(a) ?? 0)); if (mruValue) { const i = sorted.indexOf(mruValue); if (i > 0) { sorted.splice(i, 1); sorted.unshift(mruValue); } } return sorted; } function usePersistedDate(key: string): [string, (v: string) => void] { const [value, setValue] = useState(() => localStorage.getItem(key) ?? yearStart()); const set = (v: string) => { setValue(v); localStorage.setItem(key, v); }; return [value, set]; } type Tab = 'work' | 'payments' | 'expenses'; export function LedgerPage({ initialTab = 'work' }: { initialTab?: Tab }) { const [workStart, setWorkStart] = usePersistedDate('ledger_work_from'); const [paymentsStart, setPaymentsStart] = usePersistedDate('ledger_payments_from'); const [expensesStart, setExpensesStart] = usePersistedDate('ledger_expenses_from'); const today = todayISO(); const startDate = initialTab === 'work' ? workStart : initialTab === 'payments' ? paymentsStart : expensesStart; return ( {initialTab === 'work' && } {initialTab === 'payments' && } {initialTab === 'expenses' && } } right={} /> ); } // ─── Work Tab ──────────────────────────────────────────────────────────────── function WorkTab({ startDate, setStartDate }: { startDate: string; setStartDate: (v: string) => void }) { const entries = useAppStore((s) => s.data.workEntries); const defaultRate = useAppStore((s) => s.data.settings.defaultRate); const addWorkEntry = useAppStore((s) => s.addWorkEntry); const updateWorkEntry = useAppStore((s) => s.updateWorkEntry); const deleteWorkEntry = useAppStore((s) => s.deleteWorkEntry); const tiles = useAppStore((s) => s.data.dashboard.workTiles); const setPageTiles = useAppStore((s) => s.setPageTiles); const [addForDay, setAddForDay] = useState(null); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); const [configOpen, setConfigOpen] = useState(false); const today = todayISO(); const nodes = useMemo( () => buildHierarchyForRange( entries, startDate, today, (e) => workEntryValue(e as WorkEntry), (e) => { const w = e as WorkEntry; const extra = w.hours != null ? ` (${w.hours}h @ $${w.rate})` : ''; return `${w.description}${extra}${w.client ? ` — ${w.client}` : ''}`; }, ), [entries, startDate, today], ); const stats = useMemo(() => aggregate(entries, [], []), [entries]); const existingClients = useMemo( () => freqSorted(entries.map((e) => ({ value: e.client ?? '', createdAt: e.createdAt }))), [entries], ); const existingDescriptions = useMemo( () => freqSorted(entries.map((e) => ({ value: e.description, createdAt: e.createdAt }))), [entries], ); return ( <> setConfigOpen(true)} />
Work Log
setStartDate(e.target.value)} />
setAddForDay(date)} onView={(n) => setEditing(n.entry as WorkEntry)} onEdit={(n) => setEditing(n.entry as WorkEntry)} onDelete={(n) => setDeleting(n.entry!.id)} onToggleOutstanding={(n) => { const e = n.entry as WorkEntry; updateWorkEntry(e.id, { paymentOutstanding: !e.paymentOutstanding }); }} />
setAddForDay(null)}> {addForDay != null && ( { addWorkEntry(d); setAddForDay(null); }} onCancel={() => setAddForDay(null)} /> )} setEditing(null)}> {editing && ( { updateWorkEntry(editing.id, d); setEditing(null); }} onCancel={() => setEditing(null)} /> )} { if (deleting) deleteWorkEntry(deleting); setDeleting(null); }} onCancel={() => setDeleting(null)} /> setPageTiles('work', t)} onClose={() => setConfigOpen(false)} /> ); } // ─── Payments Tab ──────────────────────────────────────────────────────────── function PaymentsTab({ startDate, setStartDate }: { startDate: string; setStartDate: (v: string) => void }) { const payments = useAppStore((s) => s.data.payments); const addPayment = useAppStore((s) => s.addPayment); const updatePayment = useAppStore((s) => s.updatePayment); const deletePayment = useAppStore((s) => s.deletePayment); const tiles = useAppStore((s) => s.data.dashboard.paymentsTiles); const setPageTiles = useAppStore((s) => s.setPageTiles); const [addForDay, setAddForDay] = useState(null); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); const [configOpen, setConfigOpen] = useState(false); const today = todayISO(); const nodes = useMemo( () => buildHierarchyForRange( payments, startDate, today, (e) => (e as Payment).amount, (e) => { const p = e as Payment; return `${p.payer} (${p.form ?? 'direct'})${p.notes ? ` — ${p.notes}` : ''}`; }, ), [payments, startDate, today], ); const stats = useMemo(() => aggregate([], payments, []), [payments]); const recentForm = useMemo( () => payments.slice().sort((a, b) => b.createdAt - a.createdAt)[0]?.form, [payments], ); const existingPayers = useMemo( () => freqSorted(payments.map((p) => ({ value: p.payer, createdAt: p.createdAt }))), [payments], ); return ( <> setConfigOpen(true)} />
Payments Received
setStartDate(e.target.value)} />
setAddForDay(date)} onView={(n) => setEditing(n.entry as Payment)} onEdit={(n) => setEditing(n.entry as Payment)} onDelete={(n) => setDeleting(n.entry!.id)} />
setAddForDay(null)}> {addForDay != null && ( { addPayment(d); setAddForDay(null); }} onCancel={() => setAddForDay(null)} /> )} setEditing(null)}> {editing && ( { updatePayment(editing.id, d); setEditing(null); }} onCancel={() => setEditing(null)} /> )} { if (deleting) deletePayment(deleting); setDeleting(null); }} onCancel={() => setDeleting(null)} /> setPageTiles('payments', t)} onClose={() => setConfigOpen(false)} /> ); } // ─── Expenses Tab ──────────────────────────────────────────────────────────── function ExpensesTab({ startDate, setStartDate }: { startDate: string; setStartDate: (v: string) => void }) { const expenses = useAppStore((s) => s.data.expenses); const addExpense = useAppStore((s) => s.addExpense); const updateExpense = useAppStore((s) => s.updateExpense); const deleteExpense = useAppStore((s) => s.deleteExpense); const recurringExpenses = useAppStore((s) => s.data.recurringExpenses); const addRecurringExpense = useAppStore((s) => s.addRecurringExpense); const updateRecurringExpense = useAppStore((s) => s.updateRecurringExpense); const deleteRecurringExpense = useAppStore((s) => s.deleteRecurringExpense); const tiles = useAppStore((s) => s.data.dashboard.expensesTiles); const setPageTiles = useAppStore((s) => s.setPageTiles); const [showAdd, setShowAdd] = useState(false); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); const [showRecurring, setShowRecurring] = useState(false); const [editingRecurring, setEditingRecurring] = useState(null); const [deletingRecurring, setDeletingRecurring] = useState(null); const [configOpen, setConfigOpen] = useState(false); const today = todayISO(); const nodes = useMemo( () => buildHierarchyForRange( expenses, startDate, today, (e) => (e as Expense).amount, (e) => { const x = e as Expense; return `${x.description}${x.deductible ? ' ✓ deductible' : ''}${x.category ? ` — ${x.category}` : ''}`; }, ), [expenses, startDate, today], ); const stats = useMemo(() => aggregate([], [], expenses), [expenses]); return ( <> setConfigOpen(true)} />
Expenses
setStartDate(e.target.value)} />
setEditing(n.entry as Expense)} onEdit={(n) => setEditing(n.entry as Expense)} onDelete={(n) => setDeleting(n.entry!.id)} />
setShowAdd(false)}> { addExpense(d); setShowAdd(false); }} onCancel={() => setShowAdd(false)} /> setEditing(null)}> {editing && ( { updateExpense(editing.id, d); setEditing(null); }} onCancel={() => setEditing(null)} /> )} { if (deleting) deleteExpense(deleting); setDeleting(null); }} onCancel={() => setDeleting(null)} /> {/* ─── Recurring expenses ─────────────────────────── */}
Recurring Expenses
{recurringExpenses.length === 0 ? (
No recurring expenses set up. Add one to auto-generate expense entries.
) : ( {recurringExpenses.map((r) => ( ))}
Description Amount Frequency Since Category
{r.description}{r.deductible ? ' ✓' : ''} ${r.amount.toFixed(2)} {r.frequency} {r.startDate} {r.category ?? '—'}
)}
setShowRecurring(false)}> { addRecurringExpense(d); setShowRecurring(false); }} onCancel={() => setShowRecurring(false)} /> setEditingRecurring(null)}> {editingRecurring && ( { updateRecurringExpense(editingRecurring.id, d); setEditingRecurring(null); }} onCancel={() => setEditingRecurring(null)} /> )} { if (deletingRecurring) deleteRecurringExpense(deletingRecurring); setDeletingRecurring(null); }} onCancel={() => setDeletingRecurring(null)} /> setPageTiles('expenses', t)} onClose={() => setConfigOpen(false)} /> ); } // ─── Recurring expense form ─────────────────────────────────────────────────── type RecurringDraft = Omit; const FREQ_LABELS: Record = { weekly: 'Weekly', biweekly: 'Bi-weekly', monthly: 'Monthly', quarterly: 'Quarterly', annually: 'Annually', }; function RecurringExpenseForm({ initial, onSubmit, onCancel, }: { initial?: Partial; onSubmit: (d: RecurringDraft) => void; onCancel: () => void; }) { const [desc, setDesc] = useState(initial?.description ?? ''); const [amount, setAmount] = useState(initial?.amount?.toString() ?? ''); const [category, setCategory] = useState(initial?.category ?? ''); const [deductible, setDeductible] = useState(initial?.deductible ?? true); const [frequency, setFrequency] = useState(initial?.frequency ?? 'monthly'); const [dayOfMonth, setDayOfMonth] = useState(initial?.dayOfMonth?.toString() ?? '1'); const [startDate, setStartDate] = useState(initial?.startDate ?? todayISO()); const [endDate, setEndDate] = useState(initial?.endDate ?? ''); const submit = (e: React.FormEvent) => { e.preventDefault(); onSubmit({ description: desc, amount: parseFloat(amount) || 0, category: category || undefined, deductible, frequency, dayOfMonth: parseInt(dayOfMonth) || 1, startDate, endDate: endDate || null, }); }; return (
setDesc(e.target.value)} required placeholder="e.g. Rent, Software subscription" />
setAmount(e.target.value)} required />
{(frequency === 'monthly' || frequency === 'quarterly' || frequency === 'annually') && (
setDayOfMonth(e.target.value)} />
)}
setStartDate(e.target.value)} required />
setEndDate(e.target.value)} />
setCategory(e.target.value)} placeholder="optional" />
); } // ─── Period summary strip (configurable tiles) ─────────────────────────────── const LEDGER_TILE_LABELS: Record = { ytd: 'YTD total', avgMonth: 'Avg / month', yearProj: 'Year projected', thisMonth: 'This month', avgDay: 'YTD daily avg', monthProj: 'Month projected', today: 'Today', }; const ALL_LEDGER_TILES: LedgerTile[] = ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', 'today']; const METRIC_COLOR: Record<'workValue' | 'payments' | 'expenses', string> = { workValue: 'positive', payments: 'positive', expenses: 'negative', }; function PeriodSummaryRow({ stats, metric, label, tiles, onConfigure, }: { stats: ReturnType; metric: 'workValue' | 'payments' | 'expenses'; label: string; tiles: LedgerTile[]; onConfigure: () => void; }) { const { year: nowYear, monthIdx, day: nowDay, isoDate: today, isoMonth: currentMonth } = nowInTZ(); const currentYear = String(nowYear); const y = stats.years.find((x) => x.label === currentYear); const m = stats.months.get(currentMonth); const d = stats.days.get(today); const yValue = y?.[metric] ?? 0; const mValue = m?.[metric] ?? 0; const monthsElapsed = monthIdx + 1; const dayOfMonth = nowDay; const daysInMonth = new Date(nowYear, monthIdx + 1, 0).getDate(); // Days elapsed since Jan 1 inclusive, using noon-UTC to avoid DST drift const jan1Noon = Date.parse(`${nowYear}-01-01T12:00:00Z`); const todayNoon = Date.parse(`${today}T12:00:00Z`); const daysElapsed = Math.max(1, Math.floor((todayNoon - jan1Noon) / 86400000) + 1); const daysInYear = (nowYear % 4 === 0 && (nowYear % 100 !== 0 || nowYear % 400 === 0)) ? 366 : 365; const ytdDailyAvg = yValue / daysElapsed; const tileValues: Record = { ytd: yValue, avgMonth: yValue > 0 ? yValue / monthsElapsed : null, yearProj: yValue > 0 ? ytdDailyAvg * daysInYear : null, thisMonth: mValue, avgDay: yValue > 0 ? ytdDailyAvg : null, monthProj: mValue > 0 && dayOfMonth > 0 && dayOfMonth < daysInMonth ? (mValue / dayOfMonth) * daysInMonth : null, today: d?.[metric] ?? 0, }; const tileDisplayLabel: Record = { ...LEDGER_TILE_LABELS, ytd: `YTD ${label}`, }; return (
{tiles.map((t) => { const value = tileValues[t]; if (value == null) return null; return ; })}
); } function StatTile({ label, value, className = '' }: { label: string; value: number; className?: string }) { return (
{label}
{fmtMoney(value)}
); } // ─── Tile configure modal (shared by all ledger tabs) ──────────────────────── function TileConfigModal({ open, tiles, onChange, onClose, }: { open: boolean; tiles: LedgerTile[]; onChange: (tiles: LedgerTile[]) => void; onClose: () => void; }) { const toggle = (t: LedgerTile) => { onChange(tiles.includes(t) ? tiles.filter((x) => x !== t) : [...tiles, t]); }; return ( Done }>

Choose which tiles to display:

{ALL_LEDGER_TILES.map((t) => ( ))}
); }