updated some UI elements and calculations
This commit is contained in:
parent
409d2560bc
commit
55d7c736c9
13 changed files with 378 additions and 121 deletions
|
|
@ -45,7 +45,7 @@ export function App() {
|
||||||
{saveError && <span className="text-danger" title={saveError}>⚠ Save failed</span>}
|
{saveError && <span className="text-danger" title={saveError}>⚠ Save failed</span>}
|
||||||
</div>
|
</div>
|
||||||
<button className="kofi-header-btn" onClick={() => setKofiOpen(true)}>
|
<button className="kofi-header-btn" onClick={() => setKofiOpen(true)}>
|
||||||
☕ Donate
|
☕ <span className="kofi-label">Donate</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="hamburger-btn"
|
className="hamburger-btn"
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export function ChartSidebar({ tab, defaultRangeStart, defaultRangeEnd }: {
|
||||||
key={c.id}
|
key={c.id}
|
||||||
config={c}
|
config={c}
|
||||||
onChange={(patch) => updateLedgerChart(tab, c.id, patch)}
|
onChange={(patch) => updateLedgerChart(tab, c.id, patch)}
|
||||||
onRemove={(charts?.length ?? 0) > 1 ? () => removeLedgerChart(tab, c.id) : undefined}
|
onRemove={() => removeLedgerChart(tab, c.id)}
|
||||||
defaultRangeStart={defaultRangeStart}
|
defaultRangeStart={defaultRangeStart}
|
||||||
defaultRangeEnd={defaultRangeEnd}
|
defaultRangeEnd={defaultRangeEnd}
|
||||||
/>
|
/>
|
||||||
|
|
@ -55,7 +55,7 @@ export function ChartSidebar({ tab, defaultRangeStart, defaultRangeEnd }: {
|
||||||
key={c.id}
|
key={c.id}
|
||||||
config={c}
|
config={c}
|
||||||
onChange={(patch) => updateChart(c.id, patch)}
|
onChange={(patch) => updateChart(c.id, patch)}
|
||||||
onRemove={dashCharts.length > 1 ? () => removeChart(c.id) : undefined}
|
onRemove={() => removeChart(c.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<button className="btn" onClick={() => addChart()}>
|
<button className="btn" onClick={() => addChart()}>
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,9 @@ export function WorkEntryForm({
|
||||||
const [hours, setHours] = useState(initial?.hours?.toString() ?? '');
|
const [hours, setHours] = useState(initial?.hours?.toString() ?? '');
|
||||||
const [rate, setRate] = useState(initial?.rate?.toString() ?? String(defaultRate));
|
const [rate, setRate] = useState(initial?.rate?.toString() ?? String(defaultRate));
|
||||||
const [client, setClient] = useState(initial?.client ?? '');
|
const [client, setClient] = useState(initial?.client ?? '');
|
||||||
|
const [paymentOutstanding, setPaymentOutstanding] = useState(
|
||||||
|
initial?.paymentOutstanding ?? true,
|
||||||
|
);
|
||||||
|
|
||||||
const computedValue =
|
const computedValue =
|
||||||
mode === 'amount'
|
mode === 'amount'
|
||||||
|
|
@ -102,7 +105,7 @@ export function WorkEntryForm({
|
||||||
|
|
||||||
const submit = (e: React.FormEvent) => {
|
const submit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const base: WorkDraft = { date, description: desc, client: client || undefined };
|
const base: WorkDraft = { date, description: desc, client: client || undefined, paymentOutstanding };
|
||||||
if (mode === 'amount') base.amount = parseFloat(amount) || 0;
|
if (mode === 'amount') base.amount = parseFloat(amount) || 0;
|
||||||
else {
|
else {
|
||||||
base.hours = parseFloat(hours) || 0;
|
base.hours = parseFloat(hours) || 0;
|
||||||
|
|
@ -158,6 +161,15 @@ export function WorkEntryForm({
|
||||||
|
|
||||||
<div className="text-muted text-sm">Value: <span className="mono">${computedValue.toFixed(2)}</span></div>
|
<div className="text-muted text-sm">Value: <span className="mono">${computedValue.toFixed(2)}</span></div>
|
||||||
|
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={paymentOutstanding}
|
||||||
|
onChange={(e) => setPaymentOutstanding(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Payment outstanding (awaiting receipt)
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" className="btn" onClick={onCancel}>Cancel</button>
|
<button type="button" className="btn" onClick={onCancel}>Cancel</button>
|
||||||
<button type="submit" className="btn btn-primary">Save</button>
|
<button type="submit" className="btn btn-primary">Save</button>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { HierNode } from '@/types';
|
import type { HierNode, WorkEntry } from '@/types';
|
||||||
import { fmtMoney } from '@/lib/format';
|
import { fmtMoney } from '@/lib/format';
|
||||||
|
|
||||||
export type ExpandLevel = 'year' | 'month' | 'day' | 'item';
|
export type ExpandLevel = 'year' | 'month' | 'day' | 'item';
|
||||||
|
|
@ -20,6 +20,8 @@ interface Props {
|
||||||
onDelete?: (node: HierNode) => void;
|
onDelete?: (node: HierNode) => void;
|
||||||
/** Called when user clicks "+" on a day row; receives the ISO date string */
|
/** Called when user clicks "+" on a day row; receives the ISO date string */
|
||||||
onAddForDay?: (date: string) => void;
|
onAddForDay?: (date: string) => void;
|
||||||
|
/** When provided, shows a payment-outstanding toggle on item rows */
|
||||||
|
onToggleOutstanding?: (node: HierNode) => void;
|
||||||
/** Label for the value column (e.g. "Earned", "Paid", "Spent") */
|
/** Label for the value column (e.g. "Earned", "Paid", "Spent") */
|
||||||
valueLabel: string;
|
valueLabel: string;
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +57,7 @@ function filterEmpty(nodes: HierNode[]): HierNode[] {
|
||||||
.map((n) => ({ ...n, children: filterEmpty(n.children) }));
|
.map((n) => ({ ...n, children: filterEmpty(n.children) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay, valueLabel }: Props) {
|
export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay, onToggleOutstanding, valueLabel }: Props) {
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [hideEmpty, setHideEmpty] = useState(false);
|
const [hideEmpty, setHideEmpty] = useState(false);
|
||||||
const [selectedLevel, setSelectedLevel] = useState<ExpandLevel>('year');
|
const [selectedLevel, setSelectedLevel] = useState<ExpandLevel>('year');
|
||||||
|
|
@ -168,7 +170,7 @@ export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay,
|
||||||
<tr>
|
<tr>
|
||||||
<th>Period / Item</th>
|
<th>Period / Item</th>
|
||||||
<th className="num">{valueLabel}</th>
|
<th className="num">{valueLabel}</th>
|
||||||
<th style={{ width: 80 }}></th>
|
<th style={{ width: onToggleOutstanding ? 110 : 80 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -225,6 +227,19 @@ export function HierSpreadsheet({ nodes, onView, onEdit, onDelete, onAddForDay,
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isItem && onToggleOutstanding && (() => {
|
||||||
|
const outstanding = !!(node.entry as WorkEntry)?.paymentOutstanding;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-ghost ss-outstanding-btn"
|
||||||
|
data-outstanding={outstanding}
|
||||||
|
title={outstanding ? 'Payment outstanding — click to mark received' : 'Click to mark payment outstanding'}
|
||||||
|
onClick={() => onToggleOutstanding(node)}
|
||||||
|
>
|
||||||
|
{outstanding ? '⏳' : '·'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{isItem && onEdit && (
|
{isItem && onEdit && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-ghost"
|
className="btn btn-sm btn-ghost"
|
||||||
|
|
|
||||||
|
|
@ -146,10 +146,11 @@ function RangeBar({
|
||||||
type WorkDraft = {
|
type WorkDraft = {
|
||||||
date: string; description: string; client: string;
|
date: string; description: string; client: string;
|
||||||
hours: string; rate: string; amount: string;
|
hours: string; rate: string; amount: string;
|
||||||
|
paymentOutstanding: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function blankWork(today: string, defaultRate: number): WorkDraft {
|
function blankWork(today: string, defaultRate: number): WorkDraft {
|
||||||
return { date: today, description: '', client: '', hours: '', rate: String(defaultRate), amount: '' };
|
return { date: today, description: '', client: '', hours: '', rate: String(defaultRate), amount: '', paymentOutstanding: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkSheet({
|
export function WorkSheet({
|
||||||
|
|
@ -192,6 +193,7 @@ export function WorkSheet({
|
||||||
hours,
|
hours,
|
||||||
rate: hours != null ? rate : undefined,
|
rate: hours != null ? rate : undefined,
|
||||||
amount,
|
amount,
|
||||||
|
paymentOutstanding: draft.paymentOutstanding,
|
||||||
});
|
});
|
||||||
setDraft(blankWork(today, defaultRate));
|
setDraft(blankWork(today, defaultRate));
|
||||||
};
|
};
|
||||||
|
|
@ -211,6 +213,7 @@ export function WorkSheet({
|
||||||
<th className="ss-right">$/hr</th>
|
<th className="ss-right">$/hr</th>
|
||||||
<th className="ss-right">Flat $</th>
|
<th className="ss-right">Flat $</th>
|
||||||
<th className="ss-right">Value</th>
|
<th className="ss-right">Value</th>
|
||||||
|
<th style={{ width: 56, textAlign: 'center' }} title="Payment outstanding">Unpaid</th>
|
||||||
<th style={{ width: 40 }}></th>
|
<th style={{ width: 40 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -245,6 +248,16 @@ export function WorkSheet({
|
||||||
<td className="ss-right mono ss-value text-muted">
|
<td className="ss-right mono ss-value text-muted">
|
||||||
{draftValue > 0 ? fmtMoney(draftValue) : '—'}
|
{draftValue > 0 ? fmtMoney(draftValue) : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ textAlign: 'center', padding: '0 4px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-ghost ss-outstanding-btn"
|
||||||
|
data-outstanding={draft.paymentOutstanding}
|
||||||
|
title={draft.paymentOutstanding ? 'Marked unpaid — click to clear' : 'Click to mark unpaid'}
|
||||||
|
onClick={() => setDraft({ ...draft, paymentOutstanding: !draft.paymentOutstanding })}
|
||||||
|
>
|
||||||
|
{draft.paymentOutstanding ? '⏳' : '·'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td className="ss-actions">
|
<td className="ss-actions">
|
||||||
<button className="btn btn-sm btn-primary" onClick={addRow}
|
<button className="btn btn-sm btn-primary" onClick={addRow}
|
||||||
disabled={!draft.description.trim()} title="Add row">
|
disabled={!draft.description.trim()} title="Add row">
|
||||||
|
|
@ -255,7 +268,7 @@ export function WorkSheet({
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
<td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||||
No entries in this period
|
No entries in this period
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -288,6 +301,16 @@ export function WorkSheet({
|
||||||
});
|
});
|
||||||
}} />
|
}} />
|
||||||
<td className="ss-right mono ss-value">{fmtMoney(val)}</td>
|
<td className="ss-right mono ss-value">{fmtMoney(val)}</td>
|
||||||
|
<td style={{ textAlign: 'center', padding: '0 4px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-ghost ss-outstanding-btn"
|
||||||
|
data-outstanding={!!e.paymentOutstanding}
|
||||||
|
title={e.paymentOutstanding ? 'Payment outstanding — click to mark received' : 'Click to mark payment outstanding'}
|
||||||
|
onClick={() => onUpdate(e.id, { paymentOutstanding: !e.paymentOutstanding })}
|
||||||
|
>
|
||||||
|
{e.paymentOutstanding ? '⏳' : '·'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td className="ss-actions">
|
<td className="ss-actions">
|
||||||
<button className="btn btn-sm btn-ghost text-danger"
|
<button className="btn btn-sm btn-ghost text-danger"
|
||||||
onClick={() => setDeleting(e.id)} title="Delete">
|
onClick={() => setDeleting(e.id)} title="Delete">
|
||||||
|
|
@ -302,7 +325,7 @@ export function WorkSheet({
|
||||||
<tr className="ss-total">
|
<tr className="ss-total">
|
||||||
<td colSpan={6}>Total</td>
|
<td colSpan={6}>Total</td>
|
||||||
<td className="ss-right mono">{fmtMoney(total)}</td>
|
<td className="ss-right mono">{fmtMoney(total)}</td>
|
||||||
<td></td>
|
<td colSpan={2}></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,39 @@ export function fmtPct(n: number): string {
|
||||||
return (n * 100).toFixed(1) + '%';
|
return (n * 100).toFixed(1) + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function todayISO(): string {
|
// ─── Timezone ────────────────────────────────────────────────────────────────
|
||||||
return new Date().toISOString().slice(0, 10);
|
|
||||||
|
let _tz: string | undefined;
|
||||||
|
|
||||||
|
/** Called by the store whenever the timezone setting changes. */
|
||||||
|
export function setActiveTZ(tz: string | undefined) { _tz = tz; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current date/time broken down in the active timezone.
|
||||||
|
* All date-sensitive calculations should use this instead of `new Date()` directly.
|
||||||
|
*/
|
||||||
|
export function nowInTZ(): {
|
||||||
|
year: number; monthIdx: number; day: number;
|
||||||
|
isoDate: string; isoMonth: string;
|
||||||
|
} {
|
||||||
|
const d = new Date();
|
||||||
|
if (_tz) {
|
||||||
|
try {
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: _tz, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
});
|
||||||
|
const isoDate = fmt.format(d); // YYYY-MM-DD
|
||||||
|
const [year, month, day] = isoDate.split('-').map(Number);
|
||||||
|
return { year, monthIdx: month - 1, day, isoDate, isoMonth: isoDate.slice(0, 7) };
|
||||||
|
} catch { /* fall through to system time */ }
|
||||||
|
}
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const monthIdx = d.getMonth();
|
||||||
|
const day = d.getDate();
|
||||||
|
const isoDate = `${year}-${String(monthIdx + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
return { year, monthIdx, day, isoDate, isoMonth: isoDate.slice(0, 7) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayISO(): string {
|
||||||
|
return nowInTZ().isoDate;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,27 +6,57 @@ import { useMemo, useState } from 'react';
|
||||||
import { useAppStore } from '@/store/appStore';
|
import { useAppStore } from '@/store/appStore';
|
||||||
import { aggregate } from '@/lib/stats/aggregate';
|
import { aggregate } from '@/lib/stats/aggregate';
|
||||||
import { calculateTax } from '@/lib/tax/calculate';
|
import { calculateTax } from '@/lib/tax/calculate';
|
||||||
import { fmtMoney, fmtMoneyShort } from '@/lib/format';
|
import { fmtMoney, fmtMoneyShort, nowInTZ } from '@/lib/format';
|
||||||
import { ChartPanel } from '@/components/charts/ChartPanel';
|
import { ChartPanel } from '@/components/charts/ChartPanel';
|
||||||
import { Modal } from '@/components/common/Modal';
|
import { Modal } from '@/components/common/Modal';
|
||||||
|
import { workEntryValue } from '@/types';
|
||||||
import type { DashboardWidget } from '@/types';
|
import type { DashboardWidget } from '@/types';
|
||||||
|
|
||||||
const WIDGET_LABELS: Record<DashboardWidget, string> = {
|
const WIDGET_LABELS: Record<DashboardWidget, string> = {
|
||||||
ytdWorkValue: 'YTD Work Value',
|
ytdWorkValue: 'YTD Work Value',
|
||||||
ytdWorkProj: 'Work Value Projected',
|
ytdWorkProj: 'Work Value Projected',
|
||||||
ytdPayments: 'YTD Payments',
|
ytdDailyAvg: 'YTD Daily Average',
|
||||||
|
ytdPayments: 'YTD Payments Received',
|
||||||
ytdPaymentsProj: 'Payments Projected',
|
ytdPaymentsProj: 'Payments Projected',
|
||||||
|
expectedPayments: 'Expected Payments',
|
||||||
ytdExpenses: 'YTD Expenses',
|
ytdExpenses: 'YTD Expenses',
|
||||||
|
avgDailyExpenses: 'YTD Daily Average',
|
||||||
ytdNet: 'YTD Net Income',
|
ytdNet: 'YTD Net Income',
|
||||||
ytdNetProj: 'Net Income Projected',
|
ytdNetProj: 'Net Income Projected',
|
||||||
nextQuarterlyDue: 'Next Quarterly Due',
|
|
||||||
projectedAnnualTax: 'Projected Annual Tax',
|
|
||||||
ytdActualTax: 'YTD Actual Tax',
|
|
||||||
taxRemainingDue: 'Tax Remaining Due',
|
|
||||||
avgMonthlyNet: 'Avg Monthly Net',
|
avgMonthlyNet: 'Avg Monthly Net',
|
||||||
avgDailyWork: 'Avg Daily Work',
|
nextQuarterlyDue: 'Next Quarterly Due',
|
||||||
|
ytdActualTax: 'YTD Actual Tax',
|
||||||
|
projectedAnnualTax: 'Projected Annual Tax',
|
||||||
|
taxRemainingDue: 'Tax Remaining Due',
|
||||||
|
effectiveTaxRate: 'Effective Tax Rate',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pairs: actual widget + optional projected widget displayed together in one card
|
||||||
|
type WidgetEntry = { actual: DashboardWidget; proj?: DashboardWidget };
|
||||||
|
|
||||||
|
const WIDGET_GROUPS: { label: string; entries: WidgetEntry[] }[] = [
|
||||||
|
{ label: 'Work', entries: [
|
||||||
|
{ actual: 'ytdWorkValue', proj: 'ytdWorkProj' },
|
||||||
|
{ actual: 'ytdDailyAvg' },
|
||||||
|
]},
|
||||||
|
{ label: 'Payments', entries: [
|
||||||
|
{ actual: 'ytdPayments', proj: 'ytdPaymentsProj' },
|
||||||
|
{ actual: 'expectedPayments' },
|
||||||
|
]},
|
||||||
|
{ label: 'Expenses', entries: [
|
||||||
|
{ actual: 'ytdExpenses' },
|
||||||
|
{ actual: 'avgDailyExpenses' },
|
||||||
|
{ actual: 'ytdNet', proj: 'ytdNetProj' },
|
||||||
|
{ actual: 'avgMonthlyNet' },
|
||||||
|
]},
|
||||||
|
{ label: 'Tax', entries: [
|
||||||
|
{ actual: 'nextQuarterlyDue' },
|
||||||
|
{ actual: 'ytdActualTax', proj: 'projectedAnnualTax' },
|
||||||
|
{ actual: 'taxRemainingDue' },
|
||||||
|
{ actual: 'effectiveTaxRate' },
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const data = useAppStore((s) => s.data);
|
const data = useAppStore((s) => s.data);
|
||||||
const addChart = useAppStore((s) => s.addChart);
|
const addChart = useAppStore((s) => s.addChart);
|
||||||
|
|
@ -41,7 +71,7 @@ export function DashboardPage() {
|
||||||
[data.workEntries, data.payments, data.expenses],
|
[data.workEntries, data.payments, data.expenses],
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
const { year: currentYear, isoDate: todayISO } = nowInTZ();
|
||||||
const currentYearStats = stats.years.find((y) => y.label === String(currentYear));
|
const currentYearStats = stats.years.find((y) => y.label === String(currentYear));
|
||||||
|
|
||||||
const taxInputs = data.taxInputs[currentYear] ?? { taxYear: currentYear, filingStatus: 'single' as const };
|
const taxInputs = data.taxInputs[currentYear] ?? { taxYear: currentYear, filingStatus: 'single' as const };
|
||||||
|
|
@ -67,58 +97,57 @@ export function DashboardPage() {
|
||||||
|
|
||||||
const nextQuarter = taxResult.quarterlySchedule.find((q) => !q.isPastDue);
|
const nextQuarter = taxResult.quarterlySchedule.find((q) => !q.isPastDue);
|
||||||
|
|
||||||
// Year projection fraction (day-based for work/payment/expense projections)
|
// Days elapsed since Jan 1 (inclusive), using noon-UTC to avoid DST drift
|
||||||
const now = new Date();
|
const jan1Noon = Date.parse(`${currentYear}-01-01T12:00:00Z`);
|
||||||
const yearStart = new Date(now.getFullYear(), 0, 1).getTime();
|
const todayNoon = Date.parse(`${todayISO}T12:00:00Z`);
|
||||||
const yearEnd = new Date(now.getFullYear() + 1, 0, 1).getTime();
|
const daysElapsed = Math.max(1, Math.floor((todayNoon - jan1Noon) / 86400000) + 1);
|
||||||
const yearFrac = (now.getTime() - yearStart) / (yearEnd - yearStart);
|
const daysInYear = (currentYear % 4 === 0 && (currentYear % 100 !== 0 || currentYear % 400 === 0)) ? 366 : 365;
|
||||||
const proj = (v: number) => (v > 0 && yearFrac > 0 && yearFrac < 1) ? v / yearFrac : null;
|
const dailyProj = (v: number) => v / daysElapsed * daysInYear;
|
||||||
|
|
||||||
const ytdWork = currentYearStats?.workValue ?? 0;
|
const ytdWork = currentYearStats?.workValue ?? 0;
|
||||||
const ytdPayments = currentYearStats?.payments ?? 0;
|
const ytdPayments = currentYearStats?.payments ?? 0;
|
||||||
const ytdExpenses = currentYearStats?.expenses ?? 0;
|
const ytdExpenses = currentYearStats?.expenses ?? 0;
|
||||||
const ytdNet = currentYearStats?.net ?? 0;
|
const ytdNet = currentYearStats?.net ?? 0;
|
||||||
|
|
||||||
|
const dailyAvgWork = ytdWork / daysElapsed;
|
||||||
|
const dailyAvgExpenses = ytdExpenses / daysElapsed;
|
||||||
|
|
||||||
|
const projNet = dailyProj(ytdNet);
|
||||||
|
const avgMonthlyNetVal = currentYearStats?.avgPerChild ?? 0;
|
||||||
|
const outstandingVal = data.workEntries
|
||||||
|
.filter((e) => e.date.startsWith(String(currentYear)) && e.paymentOutstanding)
|
||||||
|
.reduce((s, e) => s + workEntryValue(e), 0);
|
||||||
|
|
||||||
const widgets: Record<DashboardWidget, { value: string; sub?: string; className?: string }> = {
|
const widgets: Record<DashboardWidget, { value: string; sub?: string; className?: string }> = {
|
||||||
ytdWorkValue: { value: fmtMoneyShort(ytdWork) },
|
// ── Work (income) ──────────────────────────────────────────────────────────
|
||||||
ytdWorkProj: { value: fmtMoneyShort(proj(ytdWork)), sub: 'full year est.' },
|
ytdWorkValue: { value: fmtMoneyShort(ytdWork), className: 'positive' },
|
||||||
ytdPayments: { value: fmtMoneyShort(ytdPayments) },
|
ytdWorkProj: { value: fmtMoneyShort(dailyProj(ytdWork)), sub: 'full year est.', className: 'positive' },
|
||||||
ytdPaymentsProj: { value: fmtMoneyShort(proj(ytdPayments)), sub: 'full year est.' },
|
ytdDailyAvg: { value: fmtMoney(dailyAvgWork), sub: `over ${daysElapsed} days YTD`, className: 'positive' },
|
||||||
ytdExpenses: { value: fmtMoneyShort(ytdExpenses), className: 'negative' },
|
// ── Payments (income) ──────────────────────────────────────────────────────
|
||||||
ytdNet: {
|
ytdPayments: { value: fmtMoneyShort(ytdPayments), className: 'positive' },
|
||||||
value: fmtMoneyShort(ytdNet),
|
ytdPaymentsProj: { value: fmtMoneyShort(dailyProj(ytdPayments)), sub: 'full year est.', className: 'positive' },
|
||||||
className: ytdNet >= 0 ? 'positive' : 'negative',
|
expectedPayments: { value: fmtMoneyShort(outstandingVal), sub: 'work logged, payment pending', className: 'positive' },
|
||||||
},
|
// ── Expenses (cost) ────────────────────────────────────────────────────────
|
||||||
ytdNetProj: { value: fmtMoneyShort(proj(ytdNet)), sub: 'full year est.' },
|
ytdExpenses: { value: fmtMoneyShort(ytdExpenses), className: 'negative' },
|
||||||
|
avgDailyExpenses: { value: fmtMoney(dailyAvgExpenses), sub: `over ${daysElapsed} days YTD`, className: 'negative' },
|
||||||
|
// ── Net (conditional) ──────────────────────────────────────────────────────
|
||||||
|
ytdNet: { value: fmtMoneyShort(ytdNet), className: ytdNet >= 0 ? 'positive' : 'negative' },
|
||||||
|
ytdNetProj: { value: fmtMoneyShort(projNet), sub: 'full year est.', className: projNet >= 0 ? 'positive' : 'negative' },
|
||||||
|
avgMonthlyNet: { value: fmtMoneyShort(avgMonthlyNetVal), sub: `${currentYearStats?.childCount ?? 0} months`, className: avgMonthlyNetVal >= 0 ? 'positive' : 'negative' },
|
||||||
|
// ── Tax (cost / neutral) ───────────────────────────────────────────────────
|
||||||
nextQuarterlyDue: {
|
nextQuarterlyDue: {
|
||||||
value: nextQuarter ? fmtMoneyShort(nextQuarter.remainingAmount) : '—',
|
value: nextQuarter ? fmtMoneyShort(nextQuarter.remainingAmount) : '—',
|
||||||
sub: nextQuarter ? `Q${nextQuarter.quarter} due ${nextQuarter.dueDate}` : 'All paid',
|
sub: nextQuarter ? `Q${nextQuarter.quarter} due ${nextQuarter.dueDate}` : 'All paid',
|
||||||
},
|
|
||||||
projectedAnnualTax: {
|
|
||||||
value: fmtMoneyShort(taxResult.totalFederalTax),
|
|
||||||
sub: 'full year est.',
|
|
||||||
className: 'negative',
|
className: 'negative',
|
||||||
},
|
},
|
||||||
ytdActualTax: {
|
ytdActualTax: { value: fmtMoneyShort(ytdTaxResult.totalFederalTax), className: 'negative' },
|
||||||
value: fmtMoneyShort(ytdTaxResult.totalFederalTax),
|
projectedAnnualTax: { value: fmtMoneyShort(taxResult.totalFederalTax), sub: 'projected full year', className: 'negative' },
|
||||||
sub: 'based on actual YTD',
|
taxRemainingDue: { value: fmtMoneyShort(ytdTaxResult.remainingDue), sub: 'actual tax vs. payments made', className: 'negative' },
|
||||||
className: 'negative',
|
effectiveTaxRate: {
|
||||||
},
|
value: ytdTaxResult.grossReceipts > 0
|
||||||
taxRemainingDue: {
|
? `${(ytdTaxResult.totalFederalTax / ytdTaxResult.grossReceipts * 100).toFixed(1)}%`
|
||||||
value: fmtMoneyShort(taxResult.remainingDue),
|
: '—',
|
||||||
sub: 'after payments made',
|
sub: 'total tax ÷ gross income',
|
||||||
className: 'negative',
|
|
||||||
},
|
|
||||||
avgMonthlyNet: {
|
|
||||||
value: fmtMoneyShort(currentYearStats?.avgPerChild ?? 0),
|
|
||||||
sub: `${currentYearStats?.childCount ?? 0} months`,
|
|
||||||
},
|
|
||||||
avgDailyWork: {
|
|
||||||
value: fmtMoneyShort(
|
|
||||||
[...stats.days.values()].reduce((s, d) => s + d.workValue, 0) /
|
|
||||||
Math.max(1, stats.days.size),
|
|
||||||
),
|
|
||||||
sub: `${stats.days.size} days logged`,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -138,15 +167,33 @@ export function DashboardPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stat widgets */}
|
{/* Stat widgets — grouped horizontally */}
|
||||||
<div className="stat-grid">
|
<div className="dashboard-groups">
|
||||||
{data.dashboard.widgets.map((w) => {
|
{WIDGET_GROUPS.map((group) => {
|
||||||
const def = widgets[w];
|
const cards = group.entries.flatMap(({ actual, proj }) => {
|
||||||
|
const aActive = data.dashboard.widgets.includes(actual);
|
||||||
|
const pActive = !!proj && data.dashboard.widgets.includes(proj);
|
||||||
|
if (!aActive && !pActive) return [];
|
||||||
|
return [{ actual: aActive ? actual : proj!, proj: aActive && pActive ? proj : undefined }];
|
||||||
|
});
|
||||||
|
if (cards.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={w} className={`stat-card ${def.className ?? ''}`}>
|
<div key={group.label} className="dashboard-group">
|
||||||
<div className="stat-label">{WIDGET_LABELS[w]}</div>
|
<div className="dashboard-group-label">{group.label}</div>
|
||||||
<div className="stat-value">{def.value}</div>
|
<div className="stat-grid">
|
||||||
{def.sub && <div className="stat-sub">{def.sub}</div>}
|
{cards.map(({ actual, proj }) => {
|
||||||
|
const def = widgets[actual];
|
||||||
|
const projDef = proj ? widgets[proj] : undefined;
|
||||||
|
return (
|
||||||
|
<div key={actual} className={`stat-card ${def.className ?? ''}`}>
|
||||||
|
<div className="stat-label">{WIDGET_LABELS[actual]}</div>
|
||||||
|
<div className="stat-value">{def.value}</div>
|
||||||
|
{projDef && <div className="stat-proj">proj {projDef.value}</div>}
|
||||||
|
{def.sub && <div className="stat-sub">{def.sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -159,7 +206,7 @@ export function DashboardPage() {
|
||||||
key={c.id}
|
key={c.id}
|
||||||
config={c}
|
config={c}
|
||||||
onChange={(patch) => updateChart(c.id, patch)}
|
onChange={(patch) => updateChart(c.id, patch)}
|
||||||
onRemove={data.dashboard.charts.length > 1 ? () => removeChart(c.id) : undefined}
|
onRemove={() => removeChart(c.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -173,17 +220,23 @@ export function DashboardPage() {
|
||||||
onClose={() => setConfigOpen(false)}
|
onClose={() => setConfigOpen(false)}
|
||||||
footer={<button className="btn btn-primary" onClick={() => setConfigOpen(false)}>Done</button>}
|
footer={<button className="btn btn-primary" onClick={() => setConfigOpen(false)}>Done</button>}
|
||||||
>
|
>
|
||||||
<div className="flex-col gap-2">
|
<div className="flex-col gap-4">
|
||||||
<p className="text-sm text-muted">Choose which stats appear at the top:</p>
|
{WIDGET_GROUPS.map((group) => (
|
||||||
{(Object.keys(WIDGET_LABELS) as DashboardWidget[]).map((w) => (
|
<div key={group.label} className="flex-col gap-2">
|
||||||
<label key={w} className="checkbox">
|
<p className="text-sm" style={{ fontWeight: 600 }}>{group.label}</p>
|
||||||
<input
|
{group.entries.flatMap(({ actual, proj }) =>
|
||||||
type="checkbox"
|
[actual, ...(proj ? [proj] : [])].map((w) => (
|
||||||
checked={data.dashboard.widgets.includes(w)}
|
<label key={w} className="checkbox">
|
||||||
onChange={() => toggleWidget(w)}
|
<input
|
||||||
/>
|
type="checkbox"
|
||||||
{WIDGET_LABELS[w]}
|
checked={data.dashboard.widgets.includes(w)}
|
||||||
</label>
|
onChange={() => toggleWidget(w)}
|
||||||
|
/>
|
||||||
|
{WIDGET_LABELS[w]}
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { ChartSidebar } from '@/components/charts/ChartSidebar';
|
||||||
import { ResizableSplit } from '@/components/layout/ResizableSplit';
|
import { ResizableSplit } from '@/components/layout/ResizableSplit';
|
||||||
import { Modal, ConfirmDialog } from '@/components/common/Modal';
|
import { Modal, ConfirmDialog } from '@/components/common/Modal';
|
||||||
import { buildHierarchyForRange, buildHierarchy, aggregate } from '@/lib/stats/aggregate';
|
import { buildHierarchyForRange, buildHierarchy, aggregate } from '@/lib/stats/aggregate';
|
||||||
import { fmtMoney, todayISO } from '@/lib/format';
|
import { fmtMoney, todayISO, nowInTZ } from '@/lib/format';
|
||||||
|
|
||||||
function yearStart() { return `${new Date().getFullYear()}-01-01`; }
|
function yearStart() { return `${new Date().getFullYear()}-01-01`; }
|
||||||
|
|
||||||
|
|
@ -148,6 +148,10 @@ function WorkTab({ startDate, setStartDate }: { startDate: string; setStartDate:
|
||||||
onView={(n) => setEditing(n.entry as WorkEntry)}
|
onView={(n) => setEditing(n.entry as WorkEntry)}
|
||||||
onEdit={(n) => setEditing(n.entry as WorkEntry)}
|
onEdit={(n) => setEditing(n.entry as WorkEntry)}
|
||||||
onDelete={(n) => setDeleting(n.entry!.id)}
|
onDelete={(n) => setDeleting(n.entry!.id)}
|
||||||
|
onToggleOutstanding={(n) => {
|
||||||
|
const e = n.entry as WorkEntry;
|
||||||
|
updateWorkEntry(e.id, { paymentOutstanding: !e.paymentOutstanding });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -606,13 +610,19 @@ const LEDGER_TILE_LABELS: Record<LedgerTile, string> = {
|
||||||
avgMonth: 'Avg / month',
|
avgMonth: 'Avg / month',
|
||||||
yearProj: 'Year projected',
|
yearProj: 'Year projected',
|
||||||
thisMonth: 'This month',
|
thisMonth: 'This month',
|
||||||
avgDay: 'Avg / day',
|
avgDay: 'YTD daily avg',
|
||||||
monthProj: 'Month projected',
|
monthProj: 'Month projected',
|
||||||
today: 'Today',
|
today: 'Today',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_LEDGER_TILES: LedgerTile[] = ['ytd', 'avgMonth', 'yearProj', 'thisMonth', 'avgDay', 'monthProj', '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({
|
function PeriodSummaryRow({
|
||||||
stats,
|
stats,
|
||||||
metric,
|
metric,
|
||||||
|
|
@ -626,10 +636,8 @@ function PeriodSummaryRow({
|
||||||
tiles: LedgerTile[];
|
tiles: LedgerTile[];
|
||||||
onConfigure: () => void;
|
onConfigure: () => void;
|
||||||
}) {
|
}) {
|
||||||
const now = new Date();
|
const { year: nowYear, monthIdx, day: nowDay, isoDate: today, isoMonth: currentMonth } = nowInTZ();
|
||||||
const currentYear = String(now.getFullYear());
|
const currentYear = String(nowYear);
|
||||||
const currentMonth = now.toISOString().slice(0, 7);
|
|
||||||
const today = now.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const y = stats.years.find((x) => x.label === currentYear);
|
const y = stats.years.find((x) => x.label === currentYear);
|
||||||
const m = stats.months.get(currentMonth);
|
const m = stats.months.get(currentMonth);
|
||||||
|
|
@ -638,19 +646,23 @@ function PeriodSummaryRow({
|
||||||
const yValue = y?.[metric] ?? 0;
|
const yValue = y?.[metric] ?? 0;
|
||||||
const mValue = m?.[metric] ?? 0;
|
const mValue = m?.[metric] ?? 0;
|
||||||
|
|
||||||
const monthsElapsed = now.getMonth() + 1;
|
const monthsElapsed = monthIdx + 1;
|
||||||
const dayOfMonth = now.getDate();
|
const dayOfMonth = nowDay;
|
||||||
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
const daysInMonth = new Date(nowYear, monthIdx + 1, 0).getDate();
|
||||||
const yearStart = new Date(now.getFullYear(), 0, 1).getTime();
|
// Days elapsed since Jan 1 inclusive, using noon-UTC to avoid DST drift
|
||||||
const yearEnd = new Date(now.getFullYear() + 1, 0, 1).getTime();
|
const jan1Noon = Date.parse(`${nowYear}-01-01T12:00:00Z`);
|
||||||
const yearFrac = (now.getTime() - yearStart) / (yearEnd - yearStart);
|
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> = {
|
const tileValues: Record<LedgerTile, number | null> = {
|
||||||
ytd: yValue,
|
ytd: yValue,
|
||||||
avgMonth: yValue > 0 ? yValue / monthsElapsed : null,
|
avgMonth: yValue > 0 ? yValue / monthsElapsed : null,
|
||||||
yearProj: yValue > 0 && yearFrac > 0 && yearFrac < 1 ? yValue / yearFrac : null,
|
yearProj: yValue > 0 ? ytdDailyAvg * daysInYear : null,
|
||||||
thisMonth: mValue,
|
thisMonth: mValue,
|
||||||
avgDay: mValue > 0 ? mValue / dayOfMonth : null,
|
avgDay: yValue > 0 ? ytdDailyAvg : null,
|
||||||
monthProj: mValue > 0 && dayOfMonth > 0 && dayOfMonth < daysInMonth
|
monthProj: mValue > 0 && dayOfMonth > 0 && dayOfMonth < daysInMonth
|
||||||
? (mValue / dayOfMonth) * daysInMonth
|
? (mValue / dayOfMonth) * daysInMonth
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -671,16 +683,16 @@ function PeriodSummaryRow({
|
||||||
{tiles.map((t) => {
|
{tiles.map((t) => {
|
||||||
const value = tileValues[t];
|
const value = tileValues[t];
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
return <StatTile key={t} label={tileDisplayLabel[t]} value={value} />;
|
return <StatTile key={t} label={tileDisplayLabel[t]} value={value} className={METRIC_COLOR[metric]} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatTile({ label, value }: { label: string; value: number }) {
|
function StatTile({ label, value, className = '' }: { label: string; value: number; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="stat-card">
|
<div className={`stat-card ${className}`}>
|
||||||
<div className="stat-label">{label}</div>
|
<div className="stat-label">{label}</div>
|
||||||
<div className="stat-value">{fmtMoney(value)}</div>
|
<div className="stat-value">{fmtMoney(value)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,33 @@
|
||||||
* Settings — themes, default rate, and manual file import/export.
|
* Settings — themes, default rate, and manual file import/export.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useAppStore } from '@/store/appStore';
|
import { useAppStore } from '@/store/appStore';
|
||||||
import { THEME_NAMES } from '@/themes/ThemeProvider';
|
import { THEME_NAMES } from '@/themes/ThemeProvider';
|
||||||
import type { ThemeName, ThemeMode } from '@/types';
|
import type { ThemeName, ThemeMode } from '@/types';
|
||||||
import { isT99Encrypted } from '@/lib/storage/vault';
|
import { isT99Encrypted } from '@/lib/storage/vault';
|
||||||
|
|
||||||
|
const ALL_TIMEZONES: string[] = (() => {
|
||||||
|
try { return Intl.supportedValuesOf('timeZone'); } catch { return []; }
|
||||||
|
})();
|
||||||
|
const BROWSER_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const settings = useAppStore((s) => s.data.settings);
|
const settings = useAppStore((s) => s.data.settings);
|
||||||
const setTheme = useAppStore((s) => s.setTheme);
|
const setTheme = useAppStore((s) => s.setTheme);
|
||||||
const setDefaultRate = useAppStore((s) => s.setDefaultRate);
|
const setDefaultRate = useAppStore((s) => s.setDefaultRate);
|
||||||
|
const setTimezone = useAppStore((s) => s.setTimezone);
|
||||||
const exportFile = useAppStore((s) => s.exportFile);
|
const exportFile = useAppStore((s) => s.exportFile);
|
||||||
const importFile = useAppStore((s) => s.importFile);
|
const importFile = useAppStore((s) => s.importFile);
|
||||||
|
|
||||||
|
const [tzSearch, setTzSearch] = useState('');
|
||||||
|
const filteredTZ = useMemo(
|
||||||
|
() => tzSearch.trim()
|
||||||
|
? ALL_TIMEZONES.filter((tz) => tz.toLowerCase().includes(tzSearch.toLowerCase()))
|
||||||
|
: ALL_TIMEZONES,
|
||||||
|
[tzSearch],
|
||||||
|
);
|
||||||
|
|
||||||
const [exportPwd, setExportPwd] = useState('');
|
const [exportPwd, setExportPwd] = useState('');
|
||||||
|
|
||||||
const [importFileObj, setImportFileObj] = useState<File | null>(null);
|
const [importFileObj, setImportFileObj] = useState<File | null>(null);
|
||||||
|
|
@ -116,6 +130,52 @@ export function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Timezone ──────────────────────────────────────────────────── */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header"><span className="card-title">Timezone</span></div>
|
||||||
|
<p className="text-sm text-muted" style={{ marginBottom: 10 }}>
|
||||||
|
Used for date calculations and "today" boundaries.
|
||||||
|
Browser default: <strong>{BROWSER_TZ}</strong>
|
||||||
|
</p>
|
||||||
|
<div className="flex-col gap-2">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
placeholder="Search timezones…"
|
||||||
|
value={tzSearch}
|
||||||
|
onChange={(e) => setTzSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{settings.timezone && (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => { setTimezone(undefined); setTzSearch(''); }}
|
||||||
|
title="Reset to browser default"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
size={6}
|
||||||
|
value={settings.timezone ?? ''}
|
||||||
|
onChange={(e) => setTimezone(e.target.value || undefined)}
|
||||||
|
style={{ width: '100%', height: 'auto' }}
|
||||||
|
>
|
||||||
|
<option value="">— Use browser default ({BROWSER_TZ})</option>
|
||||||
|
{filteredTZ.map((tz) => (
|
||||||
|
<option key={tz} value={tz}>{tz}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{settings.timezone && (
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Active: <strong>{settings.timezone}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ─── Import / Export ───────────────────────────────────────────── */}
|
{/* ─── Import / Export ───────────────────────────────────────────── */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header"><span className="card-title">Backup & Restore</span></div>
|
<div className="card-header"><span className="card-title">Backup & Restore</span></div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { useAppStore } from '@/store/appStore';
|
||||||
import { calculateTax } from '@/lib/tax/calculate';
|
import { calculateTax } from '@/lib/tax/calculate';
|
||||||
import { availableTaxYears } from '@/lib/tax/brackets';
|
import { availableTaxYears } from '@/lib/tax/brackets';
|
||||||
import type { FilingStatus, TaxInputs, TaxTile } from '@/types';
|
import type { FilingStatus, TaxInputs, TaxTile } from '@/types';
|
||||||
import { ChartSidebar } from '@/components/charts/ChartSidebar';
|
|
||||||
import { Modal } from '@/components/common/Modal';
|
import { Modal } from '@/components/common/Modal';
|
||||||
import { fmtMoney, fmtMoneyShort, todayISO } from '@/lib/format';
|
import { fmtMoney, fmtMoneyShort, todayISO } from '@/lib/format';
|
||||||
|
|
||||||
|
|
@ -113,10 +112,9 @@ export function TaxPage() {
|
||||||
setNewPaymentNote('');
|
setNewPaymentNote('');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const left = (
|
||||||
<div className="split-layout">
|
<div className="flex-col gap-4">
|
||||||
<div className="left">
|
{/* ─── Year / filing status / projection toggle ───────────────── */}
|
||||||
{/* ─── Year / filing status / projection toggle ───────────────── */}
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -205,9 +203,9 @@ export function TaxPage() {
|
||||||
<div className="stat-label">{TAX_TILE_LABELS[t]}</div>
|
<div className="stat-label">{TAX_TILE_LABELS[t]}</div>
|
||||||
<div className="stat-value">{fmtMoneyShort(value)}</div>
|
<div className="stat-value">{fmtMoneyShort(value)}</div>
|
||||||
{projValue !== undefined && (
|
{projValue !== undefined && (
|
||||||
<div className="stat-sub">projected: {fmtMoneyShort(projValue)}</div>
|
<div className="stat-proj">proj {fmtMoneyShort(projValue)}</div>
|
||||||
)}
|
)}
|
||||||
{sub && !projValue && <div className="stat-sub">{sub}</div>}
|
{sub && <div className="stat-sub">{sub}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -430,13 +428,10 @@ export function TaxPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="right">
|
|
||||||
<ChartSidebar tab="tax" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return left;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BreakdownRow({ label, value, proj, bold, highlight }: {
|
function BreakdownRow({ label, value, proj, bold, highlight }: {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import type {
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { uid } from '@/lib/id';
|
import { uid } from '@/lib/id';
|
||||||
import { Vault, deserializeT99 } from '@/lib/storage/vault';
|
import { Vault, deserializeT99 } from '@/lib/storage/vault';
|
||||||
import { todayISO } from '@/lib/format';
|
import { todayISO, setActiveTZ } from '@/lib/format';
|
||||||
|
|
||||||
// ─── Defaults ────────────────────────────────────────────────────────────────
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -60,15 +60,19 @@ const defaultDashboard = (): DashboardConfig => ({
|
||||||
widgets: [
|
widgets: [
|
||||||
'ytdWorkValue',
|
'ytdWorkValue',
|
||||||
'ytdWorkProj',
|
'ytdWorkProj',
|
||||||
|
'ytdDailyAvg',
|
||||||
'ytdPayments',
|
'ytdPayments',
|
||||||
'ytdPaymentsProj',
|
'ytdPaymentsProj',
|
||||||
|
'expectedPayments',
|
||||||
'ytdExpenses',
|
'ytdExpenses',
|
||||||
|
'avgDailyExpenses',
|
||||||
'ytdNet',
|
'ytdNet',
|
||||||
'ytdNetProj',
|
'ytdNetProj',
|
||||||
'nextQuarterlyDue',
|
'nextQuarterlyDue',
|
||||||
'projectedAnnualTax',
|
|
||||||
'ytdActualTax',
|
'ytdActualTax',
|
||||||
|
'projectedAnnualTax',
|
||||||
'taxRemainingDue',
|
'taxRemainingDue',
|
||||||
|
'effectiveTaxRate',
|
||||||
],
|
],
|
||||||
workCharts: [defaultLedgerChart('workValue', 'Work Value by Day', 10)],
|
workCharts: [defaultLedgerChart('workValue', 'Work Value by Day', 10)],
|
||||||
paymentsCharts: [defaultLedgerChart('payments', 'Payments by Day')],
|
paymentsCharts: [defaultLedgerChart('payments', 'Payments by Day')],
|
||||||
|
|
@ -205,6 +209,7 @@ interface AppStore {
|
||||||
// ─── Settings ─────────────────────────────────────────────────────────────
|
// ─── Settings ─────────────────────────────────────────────────────────────
|
||||||
setTheme: (theme: ThemeName, mode: ThemeMode) => void;
|
setTheme: (theme: ThemeName, mode: ThemeMode) => void;
|
||||||
setDefaultRate: (rate: number) => void;
|
setDefaultRate: (rate: number) => void;
|
||||||
|
setTimezone: (tz: string | undefined) => void;
|
||||||
|
|
||||||
// ─── File import/export ───────────────────────────────────────────────────
|
// ─── File import/export ───────────────────────────────────────────────────
|
||||||
/** Export a backup. Without a password the file is unencrypted plaintext. */
|
/** Export a backup. Without a password the file is unencrypted plaintext. */
|
||||||
|
|
@ -281,6 +286,14 @@ export const useAppStore = create<AppStore>((set, get) => {
|
||||||
['ytdWorkValue', 'ytdPaymentsProj', 'ytdNetProj', 'ytdActualTax', 'taxRemainingDue'].includes(w),
|
['ytdWorkValue', 'ytdPaymentsProj', 'ytdNetProj', 'ytdActualTax', 'taxRemainingDue'].includes(w),
|
||||||
);
|
);
|
||||||
if (!hasNewWidgets) data.dashboard.widgets = dd.widgets;
|
if (!hasNewWidgets) data.dashboard.widgets = dd.widgets;
|
||||||
|
// Migrate: rename avgDailyWork → ytdDailyAvg, add avgDailyExpenses
|
||||||
|
data.dashboard.widgets = data.dashboard.widgets.map(
|
||||||
|
(w) => (w as string) === 'avgDailyWork' ? 'ytdDailyAvg' : w
|
||||||
|
) as typeof data.dashboard.widgets;
|
||||||
|
if (!data.dashboard.widgets.includes('ytdDailyAvg')) data.dashboard.widgets.push('ytdDailyAvg');
|
||||||
|
if (!data.dashboard.widgets.includes('avgDailyExpenses')) data.dashboard.widgets.push('avgDailyExpenses');
|
||||||
|
if (!data.dashboard.widgets.includes('effectiveTaxRate')) data.dashboard.widgets.push('effectiveTaxRate');
|
||||||
|
setActiveTZ(data.settings.timezone);
|
||||||
set({ vault, data, ready: true });
|
set({ vault, data, ready: true });
|
||||||
// Apply any pending recurring expense occurrences
|
// Apply any pending recurring expense occurrences
|
||||||
setTimeout(() => get().applyRecurringExpenses(), 0);
|
setTimeout(() => get().applyRecurringExpenses(), 0);
|
||||||
|
|
@ -506,6 +519,11 @@ export const useAppStore = create<AppStore>((set, get) => {
|
||||||
mutate((d) => { d.settings.defaultRate = rate; });
|
mutate((d) => { d.settings.defaultRate = rate; });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setTimezone: (tz) => {
|
||||||
|
setActiveTZ(tz);
|
||||||
|
mutate((d) => { d.settings.timezone = tz; });
|
||||||
|
},
|
||||||
|
|
||||||
// ─── File import/export ─────────────────────────────────────────────────
|
// ─── File import/export ─────────────────────────────────────────────────
|
||||||
|
|
||||||
exportFile: async (password) => {
|
exportFile: async (password) => {
|
||||||
|
|
|
||||||
|
|
@ -444,10 +444,33 @@ a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ─── Stats widgets ───────────────────────────────────────────────────────── */
|
/* ─── Stats widgets ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Horizontal group grid — always 4 columns, 2x2, or 1 column; never 3+1 */
|
||||||
|
.dashboard-groups {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.dashboard-groups { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 540px) {
|
||||||
|
.dashboard-groups { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-group-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-grid {
|
.stat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
|
|
@ -455,9 +478,12 @@ a:hover { text-decoration: underline; }
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
min-height: 88px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.stat-card .stat-label { font-size: 11px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
.stat-card .stat-label { font-size: 11px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
.stat-card .stat-value { font-size: 24px; font-weight: 700; font-family: var(--font-mono); margin-top: 4px; }
|
.stat-card .stat-value { font-size: 24px; font-weight: 700; font-family: var(--font-mono); margin-top: 4px; }
|
||||||
|
.stat-card .stat-proj { font-size: 13px; font-family: var(--font-mono); color: var(--fg-muted); margin-top: 3px; }
|
||||||
.stat-card .stat-sub { font-size: 12px; color: var(--fg-muted); margin-top: 2px; }
|
.stat-card .stat-sub { font-size: 12px; color: var(--fg-muted); margin-top: 2px; }
|
||||||
.stat-card.positive .stat-value { color: var(--success); }
|
.stat-card.positive .stat-value { color: var(--success); }
|
||||||
.stat-card.negative .stat-value { color: var(--danger); }
|
.stat-card.negative .stat-value { color: var(--danger); }
|
||||||
|
|
@ -655,6 +681,10 @@ select.ss-input { cursor: pointer; }
|
||||||
.ss-value { padding: 7px 10px; }
|
.ss-value { padding: 7px 10px; }
|
||||||
.ss-actions { padding: 0 4px; text-align: center; white-space: nowrap; }
|
.ss-actions { padding: 0 4px; text-align: center; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Outstanding payment toggle */
|
||||||
|
.ss-outstanding-btn { font-size: 14px; padding: 2px 6px; opacity: 0.4; }
|
||||||
|
.ss-outstanding-btn[data-outstanding="true"] { opacity: 1; color: var(--warning, #f59e0b); }
|
||||||
|
|
||||||
/* Existing data rows */
|
/* Existing data rows */
|
||||||
.ss-row:hover { background: var(--bg-elev-2); }
|
.ss-row:hover { background: var(--bg-elev-2); }
|
||||||
.ss-row:hover .ss-cell:hover { background: var(--accent-muted); }
|
.ss-row:hover .ss-cell:hover { background: var(--accent-muted); }
|
||||||
|
|
@ -829,7 +859,7 @@ select.ss-input { cursor: pointer; }
|
||||||
.app-header { position: relative; }
|
.app-header { position: relative; }
|
||||||
.hamburger-btn { display: inline-flex; }
|
.hamburger-btn { display: inline-flex; }
|
||||||
.header-status { margin-left: auto; }
|
.header-status { margin-left: auto; }
|
||||||
.kofi-header-btn { display: none; }
|
.kofi-header-btn span.kofi-label { display: none; }
|
||||||
.app-nav {
|
.app-nav {
|
||||||
display: none;
|
display: none;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -851,7 +881,6 @@ select.ss-input { cursor: pointer; }
|
||||||
/* Small phones (≤640px) — extra compact spacing */
|
/* Small phones (≤640px) — extra compact spacing */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.app-body { padding: 10px; }
|
.app-body { padding: 10px; }
|
||||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
.stat-card .stat-value { font-size: 20px; }
|
.stat-card .stat-value { font-size: 20px; }
|
||||||
.timer-display { font-size: 36px; }
|
.timer-display { font-size: 36px; }
|
||||||
.timer-earned { font-size: 20px; }
|
.timer-earned { font-size: 20px; }
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ export interface WorkEntry {
|
||||||
rate?: number;
|
rate?: number;
|
||||||
/** Optional client/project tag */
|
/** Optional client/project tag */
|
||||||
client?: string;
|
client?: string;
|
||||||
|
/** Payment has not yet been received for this work item */
|
||||||
|
paymentOutstanding?: boolean;
|
||||||
createdAt: EpochMs;
|
createdAt: EpochMs;
|
||||||
updatedAt: EpochMs;
|
updatedAt: EpochMs;
|
||||||
}
|
}
|
||||||
|
|
@ -292,17 +294,20 @@ export interface DashboardConfig {
|
||||||
export type DashboardWidget =
|
export type DashboardWidget =
|
||||||
| 'ytdWorkValue'
|
| 'ytdWorkValue'
|
||||||
| 'ytdWorkProj'
|
| 'ytdWorkProj'
|
||||||
|
| 'ytdDailyAvg'
|
||||||
| 'ytdPayments'
|
| 'ytdPayments'
|
||||||
| 'ytdPaymentsProj'
|
| 'ytdPaymentsProj'
|
||||||
|
| 'expectedPayments'
|
||||||
| 'ytdExpenses'
|
| 'ytdExpenses'
|
||||||
|
| 'avgDailyExpenses'
|
||||||
| 'ytdNet'
|
| 'ytdNet'
|
||||||
| 'ytdNetProj'
|
| 'ytdNetProj'
|
||||||
| 'nextQuarterlyDue'
|
|
||||||
| 'projectedAnnualTax'
|
|
||||||
| 'ytdActualTax'
|
|
||||||
| 'taxRemainingDue'
|
|
||||||
| 'avgMonthlyNet'
|
| 'avgMonthlyNet'
|
||||||
| 'avgDailyWork';
|
| 'nextQuarterlyDue'
|
||||||
|
| 'ytdActualTax'
|
||||||
|
| 'projectedAnnualTax'
|
||||||
|
| 'taxRemainingDue'
|
||||||
|
| 'effectiveTaxRate';
|
||||||
|
|
||||||
export type LedgerTile =
|
export type LedgerTile =
|
||||||
| 'ytd'
|
| 'ytd'
|
||||||
|
|
@ -355,6 +360,8 @@ export interface Settings {
|
||||||
mode: ThemeMode;
|
mode: ThemeMode;
|
||||||
/** Default hourly rate, pre-fills timer & new work entries */
|
/** Default hourly rate, pre-fills timer & new work entries */
|
||||||
defaultRate: number;
|
defaultRate: number;
|
||||||
|
/** IANA timezone string e.g. "America/Chicago". Undefined = use browser default. */
|
||||||
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue