733 lines
27 KiB
TypeScript
733 lines
27 KiB
TypeScript
/**
|
|
* 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<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)}
|
|
onToggleOutstanding={(n) => {
|
|
const e = n.entry as WorkEntry;
|
|
updateWorkEntry(e.id, { paymentOutstanding: !e.paymentOutstanding });
|
|
}}
|
|
/>
|
|
</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> </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: '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<typeof aggregate>;
|
|
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<LedgerTile, number | null> = {
|
|
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<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} className={METRIC_COLOR[metric]} />;
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatTile({ label, value, className = '' }: { label: string; value: number; className?: string }) {
|
|
return (
|
|
<div className={`stat-card ${className}`}>
|
|
<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>
|
|
);
|
|
}
|