Files
Voiced/src/App.tsx
T
xavierk 7dcea15491 Add payments with TDS, invoice detail view and filterable History (Phase E2)
- Migration M8 adds payments (cash, TDS, mode, reference). Payment status is derived, never stored: unpaid, partially paid, paid, or overdue. Overpayment is rejected, TDS counts toward settling, and only issued invoices accept payments. list_invoices and get_invoice carry the paid, TDS and balance summary.
- New invoice detail view: archived-PDF preview, payments list, Record payment modal, Export, Duplicate (as a draft), Re-render and Cancel. An invoice with payments cannot be cancelled until they are deleted.
- History gets search, status, financial-year, month and client filters, sortable columns, pagination, footer totals and an FY summary. Cancelled invoices are excluded from totals. The per-row archive-status calls are gone; archiving is on the detail page.
- Rust tests for payments and the list summary; vitest for fiscal-year, money, filter and duplicate helpers.

PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
2026-10-04 17:49:25 +05:30

144 lines
4.0 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { InlineNotification, Loading, Theme } from "@carbon/react";
import { api } from "./lib/api";
import type { Settings } from "./lib/types";
import { AppShell, type AppView } from "./components/AppShell";
import Onboarding from "./views/Onboarding";
import NewInvoice from "./views/NewInvoice";
import InvoiceHistory from "./views/InvoiceHistory";
import InvoiceDetail from "./views/InvoiceDetail";
import Clients from "./views/Clients";
import { ToastProvider, useToast } from "./components/ToastProvider";
import AppSettings from "./views/AppSettings";
export default function App() {
return (
<ToastProvider>
<AppInner />
</ToastProvider>
);
}
function AppInner() {
const toast = useToast();
const [settings, setSettings] = useState<Settings | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<AppView>("new");
const [detailId, setDetailId] = useState<number | null>(null);
// A draft made by "Duplicate" that the New invoice form should open once it is shown.
const [openDraftId, setOpenDraftId] = useState<number | null>(null);
const refreshSettings = useCallback(async () => {
try {
const next = await api.getSettings();
setSettings(next);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshSettings();
}, [refreshSettings]);
if (loading) {
return (
<Theme theme="g10">
<div className="voiced-empty">
<Loading description="Loading Voiced" withOverlay={false} small />
</div>
</Theme>
);
}
if (error) {
return (
<Theme theme="g10">
<div className="voiced-page">
<InlineNotification
kind="error"
lowContrast
title="Could not start Voiced"
subtitle={error}
hideCloseButton
/>
</div>
</Theme>
);
}
if (!settings) return null;
const theme = settings.theme === "g100" ? "g100" : "g10";
if (!settings.onboarded) {
return (
<Theme theme={theme}>
<Onboarding
initial={settings}
onDone={(next) => {
setSettings(next);
setView("new");
}}
/>
</Theme>
);
}
const toggleTheme = async () => {
const next: Settings = { ...settings, theme: settings.theme === "g100" ? "g10" : "g100" };
setSettings(next);
try {
await api.saveSettings(next);
} catch (e) {
toast.error("Could not save the theme", String(e));
}
};
return (
<Theme theme={theme}>
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
<NewInvoice
settings={settings}
onSettingsChange={refreshSettings}
active={view === "new"}
onActivate={() => setView("new")}
openDraftId={openDraftId}
onDraftOpened={() => setOpenDraftId(null)}
/>
<div hidden={view !== "invoices"}>
<InvoiceHistory
settings={settings}
active={view === "invoices"}
onOpen={(id) => {
setDetailId(id);
setView("invoice");
}}
/>
</div>
{view === "invoice" && detailId !== null ? (
<InvoiceDetail
key={detailId}
invoiceId={detailId}
settings={settings}
onBack={() => setView("invoices")}
onDuplicated={(draftId) => {
setOpenDraftId(draftId);
setView("new");
}}
/>
) : null}
<div hidden={view !== "clients"}>
<Clients active={view === "clients"} />
</div>
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
</AppShell>
</Theme>
);
}