First version mostly built

This commit is contained in:
Deven Thiel 2026-03-05 17:04:52 -05:00
parent 27bb45f7df
commit 99a3dbd73c
42 changed files with 9443 additions and 3338 deletions

721
src/pages/LedgerPage.tsx Normal file
View file

@ -0,0 +1,721 @@
/**
* 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 } 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<string, number>();
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 (
<ResizableSplit
left={
<>
{initialTab === 'work' && <WorkTab startDate={workStart} setStartDate={setWorkStart} />}
{initialTab === 'payments' && <PaymentsTab startDate={paymentsStart} setStartDate={setPaymentsStart} />}
{initialTab === 'expenses' && <ExpensesTab startDate={expensesStart} setStartDate={setExpensesStart} />}
</>
}
right={<ChartSidebar tab={initialTab} defaultRangeStart={startDate} defaultRangeEnd={today} />}
/>
);
}
// ─── 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<string | null>(null);
const [editing, setEditing] = useState<WorkEntry | null>(null);
const [deleting, setDeleting] = useState<string | null>(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 (
<>
<PeriodSummaryRow
stats={stats}
metric="workValue"
label="Work value"
tiles={tiles}
onConfigure={() => setConfigOpen(true)}
/>
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div className="card-header">
<span className="card-title">Work Log</span>
<div className="flex items-center gap-2">
<label className="text-sm text-muted">From</label>
<input
type="date"
className="input"
style={{ width: 148, height: 32 }}
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<button className="btn btn-primary btn-sm" onClick={() => setAddForDay(today)}>
+ Add entry
</button>
</div>
</div>
<HierSpreadsheet
nodes={nodes}
valueLabel="Earned"
onAddForDay={(date) => setAddForDay(date)}
onView={(n) => setEditing(n.entry as WorkEntry)}
onEdit={(n) => setEditing(n.entry as WorkEntry)}
onDelete={(n) => setDeleting(n.entry!.id)}
/>
</div>
<Modal open={addForDay != null} title="Add work entry" onClose={() => setAddForDay(null)}>
{addForDay != null && (
<WorkEntryForm
initial={{ date: addForDay }}
defaultRate={defaultRate}
existingClients={existingClients}
existingDescriptions={existingDescriptions}
onSubmit={(d) => { addWorkEntry(d); setAddForDay(null); }}
onCancel={() => setAddForDay(null)}
/>
)}
</Modal>
<Modal open={editing != null} title="Edit work entry" onClose={() => setEditing(null)}>
{editing && (
<WorkEntryForm
initial={editing}
defaultRate={defaultRate}
existingClients={existingClients}
existingDescriptions={existingDescriptions}
onSubmit={(d) => { updateWorkEntry(editing.id, d); setEditing(null); }}
onCancel={() => setEditing(null)}
/>
)}
</Modal>
<ConfirmDialog
open={deleting != null}
title="Delete work entry?"
message="This cannot be undone."
confirmLabel="Delete"
danger
onConfirm={() => { if (deleting) deleteWorkEntry(deleting); setDeleting(null); }}
onCancel={() => setDeleting(null)}
/>
<TileConfigModal
open={configOpen}
tiles={tiles}
onChange={(t) => 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<string | null>(null);
const [editing, setEditing] = useState<Payment | null>(null);
const [deleting, setDeleting] = useState<string | null>(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 (
<>
<PeriodSummaryRow
stats={stats}
metric="payments"
label="Taxable income"
tiles={tiles}
onConfigure={() => setConfigOpen(true)}
/>
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div className="card-header">
<span className="card-title">Payments Received</span>
<div className="flex items-center gap-2">
<label className="text-sm text-muted">From</label>
<input
type="date"
className="input"
style={{ width: 148, height: 32 }}
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<button className="btn btn-primary btn-sm" onClick={() => setAddForDay(today)}>
+ Add payment
</button>
</div>
</div>
<HierSpreadsheet
nodes={nodes}
valueLabel="Amount"
onAddForDay={(date) => setAddForDay(date)}
onView={(n) => setEditing(n.entry as Payment)}
onEdit={(n) => setEditing(n.entry as Payment)}
onDelete={(n) => setDeleting(n.entry!.id)}
/>
</div>
<Modal open={addForDay != null} title="Add payment" onClose={() => setAddForDay(null)}>
{addForDay != null && (
<PaymentForm
initial={{ date: addForDay }}
defaultForm={recentForm}
existingPayers={existingPayers}
onSubmit={(d) => { addPayment(d); setAddForDay(null); }}
onCancel={() => setAddForDay(null)}
/>
)}
</Modal>
<Modal open={editing != null} title="Edit payment" onClose={() => setEditing(null)}>
{editing && (
<PaymentForm
initial={editing}
existingPayers={existingPayers}
onSubmit={(d) => { updatePayment(editing.id, d); setEditing(null); }}
onCancel={() => setEditing(null)}
/>
)}
</Modal>
<ConfirmDialog
open={deleting != null}
title="Delete payment?"
message="This cannot be undone."
confirmLabel="Delete"
danger
onConfirm={() => { if (deleting) deletePayment(deleting); setDeleting(null); }}
onCancel={() => setDeleting(null)}
/>
<TileConfigModal
open={configOpen}
tiles={tiles}
onChange={(t) => 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<Expense | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [showRecurring, setShowRecurring] = useState(false);
const [editingRecurring, setEditingRecurring] = useState<RecurringExpense | null>(null);
const [deletingRecurring, setDeletingRecurring] = useState<string | null>(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 (
<>
<PeriodSummaryRow
stats={stats}
metric="expenses"
label="Expenses"
tiles={tiles}
onConfigure={() => setConfigOpen(true)}
/>
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div className="card-header">
<span className="card-title">Expenses</span>
<div className="flex items-center gap-2">
<label className="text-sm text-muted">From</label>
<input
type="date"
className="input"
style={{ width: 148, height: 32 }}
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<button className="btn btn-primary btn-sm" onClick={() => setShowAdd(true)}>
+ Add expense
</button>
</div>
</div>
<HierSpreadsheet
nodes={nodes}
valueLabel="Amount"
onView={(n) => setEditing(n.entry as Expense)}
onEdit={(n) => setEditing(n.entry as Expense)}
onDelete={(n) => setDeleting(n.entry!.id)}
/>
</div>
<Modal open={showAdd} title="Add expense" onClose={() => setShowAdd(false)}>
<ExpenseForm onSubmit={(d) => { addExpense(d); setShowAdd(false); }} onCancel={() => setShowAdd(false)} />
</Modal>
<Modal open={editing != null} title="Edit expense" onClose={() => setEditing(null)}>
{editing && (
<ExpenseForm
initial={editing}
onSubmit={(d) => { updateExpense(editing.id, d); setEditing(null); }}
onCancel={() => setEditing(null)}
/>
)}
</Modal>
<ConfirmDialog
open={deleting != null}
title="Delete expense?"
message="This cannot be undone."
confirmLabel="Delete"
danger
onConfirm={() => { if (deleting) deleteExpense(deleting); setDeleting(null); }}
onCancel={() => setDeleting(null)}
/>
{/* ─── Recurring expenses ─────────────────────────── */}
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div className="card-header">
<span className="card-title">Recurring Expenses</span>
<button className="btn btn-primary btn-sm" onClick={() => setShowRecurring(true)}>
+ Add recurring
</button>
</div>
{recurringExpenses.length === 0 ? (
<div className="text-muted text-sm" style={{ padding: '12px 16px' }}>
No recurring expenses set up. Add one to auto-generate expense entries.
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Description</th>
<th className="num">Amount</th>
<th>Frequency</th>
<th>Since</th>
<th>Category</th>
<th></th>
</tr>
</thead>
<tbody>
{recurringExpenses.map((r) => (
<tr key={r.id}>
<td>{r.description}{r.deductible ? ' ✓' : ''}</td>
<td className="num">${r.amount.toFixed(2)}</td>
<td className="text-sm">{r.frequency}</td>
<td className="text-sm text-muted">{r.startDate}</td>
<td className="text-sm text-muted">{r.category ?? '—'}</td>
<td>
<div className="flex gap-1">
<button className="btn btn-sm btn-ghost" onClick={() => setEditingRecurring(r)} title="Edit"></button>
<button className="btn btn-sm btn-ghost text-danger" onClick={() => setDeletingRecurring(r.id)} title="Delete"></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<Modal open={showRecurring} title="Add recurring expense" onClose={() => setShowRecurring(false)}>
<RecurringExpenseForm
onSubmit={(d) => { addRecurringExpense(d); setShowRecurring(false); }}
onCancel={() => setShowRecurring(false)}
/>
</Modal>
<Modal open={editingRecurring != null} title="Edit recurring expense" onClose={() => setEditingRecurring(null)}>
{editingRecurring && (
<RecurringExpenseForm
initial={editingRecurring}
onSubmit={(d) => { updateRecurringExpense(editingRecurring.id, d); setEditingRecurring(null); }}
onCancel={() => setEditingRecurring(null)}
/>
)}
</Modal>
<ConfirmDialog
open={deletingRecurring != null}
title="Delete recurring expense?"
message="This removes the template but keeps existing expense entries already created from it."
confirmLabel="Delete"
danger
onConfirm={() => { if (deletingRecurring) deleteRecurringExpense(deletingRecurring); setDeletingRecurring(null); }}
onCancel={() => setDeletingRecurring(null)}
/>
<TileConfigModal
open={configOpen}
tiles={tiles}
onChange={(t) => setPageTiles('expenses', t)}
onClose={() => setConfigOpen(false)}
/>
</>
);
}
// ─── Recurring expense form ───────────────────────────────────────────────────
type RecurringDraft = Omit<RecurringExpense, 'id' | 'createdAt' | 'updatedAt'>;
const FREQ_LABELS: Record<RecurringFrequency, string> = {
weekly: 'Weekly', biweekly: 'Bi-weekly', monthly: 'Monthly',
quarterly: 'Quarterly', annually: 'Annually',
};
function RecurringExpenseForm({
initial,
onSubmit,
onCancel,
}: {
initial?: Partial<RecurringDraft>;
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<RecurringFrequency>(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 (
<form onSubmit={submit} className="flex-col gap-3">
<div className="field">
<label>Description</label>
<input className="input" value={desc} onChange={(e) => setDesc(e.target.value)} required placeholder="e.g. Rent, Software subscription" />
</div>
<div className="field-row">
<div className="field">
<label>Amount ($)</label>
<input type="number" step="0.01" className="input" value={amount} onChange={(e) => setAmount(e.target.value)} required />
</div>
<div className="field">
<label>Frequency</label>
<select className="select" value={frequency} onChange={(e) => setFrequency(e.target.value as RecurringFrequency)}>
{(Object.keys(FREQ_LABELS) as RecurringFrequency[]).map((f) => (
<option key={f} value={f}>{FREQ_LABELS[f]}</option>
))}
</select>
</div>
</div>
{(frequency === 'monthly' || frequency === 'quarterly' || frequency === 'annually') && (
<div className="field">
<label>Day of month</label>
<input type="number" className="input" min={1} max={28} value={dayOfMonth} onChange={(e) => setDayOfMonth(e.target.value)} />
</div>
)}
<div className="field-row">
<div className="field">
<label>Start date</label>
<input type="date" className="input" value={startDate} onChange={(e) => setStartDate(e.target.value)} required />
</div>
<div className="field">
<label>End date (optional)</label>
<input type="date" className="input" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
</div>
<div className="field-row">
<div className="field">
<label>Category</label>
<input className="input" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="optional" />
</div>
<div className="field">
<label>&nbsp;</label>
<label className="checkbox">
<input type="checkbox" checked={deductible} onChange={(e) => setDeductible(e.target.checked)} />
Tax deductible
</label>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={onCancel}>Cancel</button>
<button type="submit" className="btn btn-primary">Save</button>
</div>
</form>
);
}
// ─── Period summary strip (configurable tiles) ───────────────────────────────
const LEDGER_TILE_LABELS: Record<LedgerTile, string> = {
ytd: 'YTD total',
avgMonth: 'Avg / month',
yearProj: 'Year projected',
thisMonth: 'This month',
avgDay: 'Avg / day',
monthProj: 'Month projected',
today: 'Today',
};
const ALL_LEDGER_TILES: LedgerTile[] = ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', 'today'];
function PeriodSummaryRow({
stats,
metric,
label,
tiles,
onConfigure,
}: {
stats: ReturnType<typeof aggregate>;
metric: 'workValue' | 'payments' | 'expenses';
label: string;
tiles: LedgerTile[];
onConfigure: () => void;
}) {
const now = new Date();
const currentYear = String(now.getFullYear());
const currentMonth = now.toISOString().slice(0, 7);
const today = now.toISOString().slice(0, 10);
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 = now.getMonth() + 1;
const dayOfMonth = now.getDate();
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
const yearStart = new Date(now.getFullYear(), 0, 1).getTime();
const yearEnd = new Date(now.getFullYear() + 1, 0, 1).getTime();
const yearFrac = (now.getTime() - yearStart) / (yearEnd - yearStart);
const tileValues: Record<LedgerTile, number | null> = {
ytd: yValue,
avgMonth: yValue > 0 ? yValue / monthsElapsed : null,
yearProj: yValue > 0 && yearFrac > 0 && yearFrac < 1 ? yValue / yearFrac : null,
thisMonth: mValue,
avgDay: mValue > 0 ? mValue / dayOfMonth : null,
monthProj: mValue > 0 && dayOfMonth > 0 && dayOfMonth < daysInMonth
? (mValue / dayOfMonth) * daysInMonth
: null,
today: d?.[metric] ?? 0,
};
const tileDisplayLabel: Record<LedgerTile, string> = {
...LEDGER_TILE_LABELS,
ytd: `YTD ${label}`,
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 4 }}>
<button className="btn btn-sm btn-ghost" onClick={onConfigure}> Tiles</button>
</div>
<div className="stat-grid">
{tiles.map((t) => {
const value = tileValues[t];
if (value == null) return null;
return <StatTile key={t} label={tileDisplayLabel[t]} value={value} />;
})}
</div>
</div>
);
}
function StatTile({ label, value }: { label: string; value: number }) {
return (
<div className="stat-card">
<div className="stat-label">{label}</div>
<div className="stat-value">{fmtMoney(value)}</div>
</div>
);
}
// ─── 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 (
<Modal open={open} title="Configure tiles" onClose={onClose} footer={
<button className="btn btn-primary" onClick={onClose}>Done</button>
}>
<div className="flex-col gap-2">
<p className="text-sm text-muted">Choose which tiles to display:</p>
{ALL_LEDGER_TILES.map((t) => (
<label key={t} className="checkbox">
<input type="checkbox" checked={tiles.includes(t)} onChange={() => toggle(t)} />
{LEDGER_TILE_LABELS[t]}
</label>
))}
</div>
</Modal>
);
}