First version mostly built
This commit is contained in:
parent
27bb45f7df
commit
99a3dbd73c
42 changed files with 9443 additions and 3338 deletions
721
src/pages/LedgerPage.tsx
Normal file
721
src/pages/LedgerPage.tsx
Normal 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> </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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue