Add toasts, Settings tabs, Clients page and item presets (Phase E1)
- Global Carbon toast stack replaces scattered inline notices; errors persist until closed. - Settings is split into eight tabs with one Save and a remembered last tab; Numbering embeds the series settings. - New Clients page with a DataTable, shared add/edit modal, structured addresses, GST category (informational), per-client notes and payment terms. The composed address string is kept for back-compat. Deleting a client warns about invoice count; issued invoices keep their own snapshot. - Migration M7 adds the client columns and item_presets; existing clients with a GSTIN are backfilled to registered_regular. - New invoice form: client ComboBox with type-ahead and inline create, client defaults applied on selection, and an Add from preset control. - Rust tests for M7 (fresh and v6 upgrade), client validation and CRUD, presets CRUD; vitest for the toast store and new helpers. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
+14
-4
@@ -6,10 +6,20 @@ import { AppShell, type AppView } from "./components/AppShell";
|
||||
import Onboarding from "./views/Onboarding";
|
||||
import NewInvoice from "./views/NewInvoice";
|
||||
import InvoiceHistory from "./views/InvoiceHistory";
|
||||
import SeriesSettings from "./views/SeriesSettings";
|
||||
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);
|
||||
@@ -81,7 +91,7 @@ export default function App() {
|
||||
try {
|
||||
await api.saveSettings(next);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not save the theme", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,8 +108,8 @@ export default function App() {
|
||||
<div hidden={view !== "invoices"}>
|
||||
<InvoiceHistory settings={settings} active={view === "invoices"} />
|
||||
</div>
|
||||
<div hidden={view !== "series"}>
|
||||
<SeriesSettings active={view === "series"} />
|
||||
<div hidden={view !== "clients"}>
|
||||
<Clients active={view === "clients"} />
|
||||
</div>
|
||||
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
||||
</AppShell>
|
||||
|
||||
@@ -11,12 +11,12 @@ import {
|
||||
import { Asleep, Light } from "@carbon/icons-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type AppView = "new" | "invoices" | "series" | "settings";
|
||||
export type AppView = "new" | "invoices" | "clients" | "settings";
|
||||
|
||||
const NAV: { id: AppView; label: string }[] = [
|
||||
{ id: "new", label: "New invoice" },
|
||||
{ id: "invoices", label: "Invoices" },
|
||||
{ id: "series", label: "Series" },
|
||||
{ id: "clients", label: "Clients" },
|
||||
{ id: "settings", label: "Settings" },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
import { Column, Grid, InlineNotification, Modal, Select, SelectItem, TextArea, TextInput } from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import {
|
||||
GST_CATEGORY_LABELS,
|
||||
INDIAN_STATES,
|
||||
blankClient,
|
||||
type Client,
|
||||
type GstCategory,
|
||||
} from "../lib/types";
|
||||
import { composeAddress, pincodeError, splitLegacyAddress } from "../lib/clientAddress";
|
||||
import { clientGstinStateError } from "../lib/validators";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
interface ClientModalProps {
|
||||
open: boolean;
|
||||
/** The client to edit; null opens a blank one. */
|
||||
client: Client | null;
|
||||
onClose: () => void;
|
||||
onSaved: (client: Client) => void;
|
||||
/** Focus returns here when the modal closes. */
|
||||
launcherRef?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
/** Fills the structured address from the legacy free-text one when a client predates the new fields. */
|
||||
export function prepareForEdit(client: Client): { form: Client; splitFromLegacy: boolean } {
|
||||
const structured = client.addressLine1 || client.addressLine2 || client.city || client.pincode;
|
||||
if (structured || !client.address.trim()) return { form: client, splitFromLegacy: false };
|
||||
return { form: { ...client, ...splitLegacyAddress(client.address) }, splitFromLegacy: true };
|
||||
}
|
||||
|
||||
export default function ClientModal({ open, client, onClose, onSaved, launcherRef }: ClientModalProps) {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState<Client>(blankClient());
|
||||
const [split, setSplit] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prepared = prepareForEdit(client ?? blankClient());
|
||||
setForm(prepared.form);
|
||||
setSplit(prepared.splitFromLegacy);
|
||||
setBusy(false);
|
||||
setTouched(false);
|
||||
}, [open, client]);
|
||||
|
||||
const set = <K extends keyof Client>(key: K, value: Client[K]) => setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const nameProblem = form.name.trim() ? undefined : "Client name is required";
|
||||
const gstinProblem = clientGstinStateError(form.gstin, form.stateCode);
|
||||
const pinProblem = pincodeError(form.pincode);
|
||||
const termsProblem =
|
||||
form.paymentTermsDays !== null && (form.paymentTermsDays < 0 || form.paymentTermsDays > 365)
|
||||
? "Between 0 and 365 days"
|
||||
: undefined;
|
||||
const blocking = nameProblem ?? gstinProblem ?? pinProblem ?? termsProblem;
|
||||
|
||||
const submit = async () => {
|
||||
setTouched(true);
|
||||
if (blocking) return;
|
||||
setBusy(true);
|
||||
const structured = form.addressLine1 || form.addressLine2 || form.city || form.pincode;
|
||||
const toSave: Client = {
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
gstin: form.gstin.trim().toUpperCase(),
|
||||
address: structured
|
||||
? composeAddress({
|
||||
line1: form.addressLine1,
|
||||
line2: form.addressLine2,
|
||||
city: form.city,
|
||||
stateCode: form.stateCode,
|
||||
pincode: form.pincode,
|
||||
})
|
||||
: form.address,
|
||||
};
|
||||
try {
|
||||
const saved = await api.saveClient(toSave);
|
||||
toast.success(toSave.id === null ? "Client added" : "Client updated", saved.name);
|
||||
onSaved(saved);
|
||||
} catch (e) {
|
||||
toast.error("Could not save client", String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
modalHeading={client?.id ? "Edit client" : "New client"}
|
||||
primaryButtonText={busy ? "Saving…" : "Save client"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={busy}
|
||||
onRequestClose={onClose}
|
||||
onRequestSubmit={() => void submit()}
|
||||
launcherButtonRef={launcherRef}
|
||||
size="md"
|
||||
>
|
||||
<Grid narrow>
|
||||
{split ? (
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<InlineNotification
|
||||
kind="info"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="Address split automatically"
|
||||
subtitle="This client has a free-text address. Check the lines below before saving."
|
||||
/>
|
||||
</Column>
|
||||
) : null}
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextInput
|
||||
id="cm-name"
|
||||
labelText="Client name"
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
invalid={touched && Boolean(nameProblem)}
|
||||
invalidText={nameProblem}
|
||||
data-modal-primary-focus
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<Select
|
||||
id="cm-category"
|
||||
labelText="GST category"
|
||||
value={form.gstCategory}
|
||||
onChange={(e) => set("gstCategory", e.target.value as GstCategory)}
|
||||
>
|
||||
{Object.entries(GST_CATEGORY_LABELS).map(([value, text]) => (
|
||||
<SelectItem key={value} value={value} text={text} />
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="cm-gstin"
|
||||
labelText="GSTIN (optional)"
|
||||
value={form.gstin}
|
||||
onChange={(e) => set("gstin", e.target.value.toUpperCase())}
|
||||
invalid={Boolean(form.gstin.trim()) && Boolean(gstinProblem)}
|
||||
invalidText={gstinProblem}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextInput
|
||||
id="cm-line1"
|
||||
labelText="Address line 1"
|
||||
value={form.addressLine1}
|
||||
onChange={(e) => set("addressLine1", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextInput
|
||||
id="cm-line2"
|
||||
labelText="Address line 2"
|
||||
value={form.addressLine2}
|
||||
onChange={(e) => set("addressLine2", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput id="cm-city" labelText="City" value={form.city} onChange={(e) => set("city", e.target.value)} />
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={7}>
|
||||
<Select
|
||||
id="cm-state"
|
||||
labelText="State (also the default place of supply)"
|
||||
value={form.stateCode}
|
||||
onChange={(e) => set("stateCode", e.target.value)}
|
||||
>
|
||||
<SelectItem value="" text="Not set" />
|
||||
{INDIAN_STATES.map((s) => (
|
||||
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={4}>
|
||||
<TextInput
|
||||
id="cm-pin"
|
||||
labelText="Pincode"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={form.pincode}
|
||||
onChange={(e) => set("pincode", e.target.value)}
|
||||
invalid={Boolean(pinProblem)}
|
||||
invalidText={pinProblem}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="cm-po"
|
||||
labelText="Default PO number (optional)"
|
||||
value={form.poNumber}
|
||||
onChange={(e) => set("poNumber", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="cm-terms"
|
||||
labelText="Payment terms (days, blank = default)"
|
||||
inputMode="numeric"
|
||||
value={form.paymentTermsDays === null ? "" : String(form.paymentTermsDays)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
set("paymentTermsDays", raw === "" ? null : Number.isFinite(Number(raw)) ? Math.trunc(Number(raw)) : null);
|
||||
}}
|
||||
invalid={Boolean(termsProblem)}
|
||||
invalidText={termsProblem}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextArea
|
||||
id="cm-notes"
|
||||
labelText="Default notes / payment terms text"
|
||||
helperText="Copied into the invoice notes when this client is picked and the notes are empty."
|
||||
rows={3}
|
||||
value={form.defaultNotes}
|
||||
onChange={(e) => set("defaultNotes", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
Grid,
|
||||
InlineLoading,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TextInput,
|
||||
} from "@carbon/react";
|
||||
import { Add, Edit, TrashCan } from "@carbon/icons-react";
|
||||
import { api } from "../lib/api";
|
||||
import { UNIT_LABELS, blankPreset, type ItemPreset, type LineMode, type LineUnit } from "../lib/types";
|
||||
import { paiseToRupees, rupeesToPaise } from "../lib/presets";
|
||||
import { formatAmount } from "../lib/format";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
/** Saved line items: description, HSN/SAC, type, unit and amount (kept as integer paise). */
|
||||
export default function ItemPresetsPanel({ active }: { active: boolean }) {
|
||||
const toast = useToast();
|
||||
const [presets, setPresets] = useState<ItemPreset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<ItemPreset | null>(null);
|
||||
const [deleting, setDeleting] = useState<ItemPreset | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [touched, setTouched] = useState(false);
|
||||
const launcher = useRef<HTMLElement | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setPresets(await api.listItemPresets());
|
||||
} catch (e) {
|
||||
toast.error("Could not load item presets", String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void load();
|
||||
}, [active, load]);
|
||||
|
||||
const openEditor = (preset: ItemPreset, trigger: HTMLElement) => {
|
||||
launcher.current = trigger;
|
||||
setTouched(false);
|
||||
setEditing(preset);
|
||||
};
|
||||
|
||||
const set = <K extends keyof ItemPreset>(key: K, value: ItemPreset[K]) =>
|
||||
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
|
||||
const save = async () => {
|
||||
if (!editing) return;
|
||||
setTouched(true);
|
||||
if (!editing.description.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.saveItemPreset({ ...editing, description: editing.description.trim() });
|
||||
toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error("Could not save preset", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!deleting || deleting.id === null) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteItemPreset(deleting.id);
|
||||
toast.success("Preset deleted", deleting.description);
|
||||
setDeleting(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error("Could not delete preset", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <InlineLoading description="Loading item presets" />;
|
||||
|
||||
return (
|
||||
<div className="voiced-stack">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p className="voiced-muted">Saved line items you can add to an invoice in one click.</p>
|
||||
<Button
|
||||
kind="tertiary"
|
||||
size="sm"
|
||||
renderIcon={Add}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(blankPreset(), e.currentTarget)}
|
||||
>
|
||||
Add preset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{presets.length === 0 ? (
|
||||
<div className="voiced-empty">No item presets yet.</div>
|
||||
) : (
|
||||
<Table aria-label="Item presets">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Description</TableHeader>
|
||||
<TableHeader>HSN/SAC</TableHeader>
|
||||
<TableHeader>Type</TableHeader>
|
||||
<TableHeader>Amount</TableHeader>
|
||||
<TableHeader>Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{presets.map((p) => (
|
||||
<TableRow key={p.id ?? p.description}>
|
||||
<TableCell>{p.description}</TableCell>
|
||||
<TableCell>{p.hsnSac || "—"}</TableCell>
|
||||
<TableCell>{p.mode === "rate" ? UNIT_LABELS[p.unit] : "Fixed amount"}</TableCell>
|
||||
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Edit}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor({ ...p }, e.currentTarget)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={TrashCan}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => {
|
||||
launcher.current = e.currentTarget;
|
||||
setDeleting(p);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={editing !== null}
|
||||
modalHeading={editing?.id ? "Edit item preset" : "New item preset"}
|
||||
primaryButtonText={busy ? "Saving…" : "Save preset"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={busy}
|
||||
onRequestClose={() => setEditing(null)}
|
||||
onRequestSubmit={() => void save()}
|
||||
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
|
||||
size="sm"
|
||||
>
|
||||
{editing ? (
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<TextInput
|
||||
id="ip-desc"
|
||||
labelText="Description"
|
||||
value={editing.description}
|
||||
onChange={(e) => set("description", e.target.value)}
|
||||
invalid={touched && !editing.description.trim()}
|
||||
invalidText="Description is required"
|
||||
data-modal-primary-focus
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<TextInput
|
||||
id="ip-hsn"
|
||||
labelText="HSN/SAC (optional)"
|
||||
value={editing.hsnSac}
|
||||
onChange={(e) => set("hsnSac", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<Select
|
||||
id="ip-mode"
|
||||
labelText="Type"
|
||||
value={editing.mode}
|
||||
onChange={(e) => set("mode", e.target.value as LineMode)}
|
||||
>
|
||||
<SelectItem value="fixed" text="Fixed amount" />
|
||||
<SelectItem value="rate" text="Rate × quantity" />
|
||||
</Select>
|
||||
</Column>
|
||||
{editing.mode === "rate" ? (
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<Select
|
||||
id="ip-unit"
|
||||
labelText="Unit"
|
||||
value={editing.unit}
|
||||
onChange={(e) => set("unit", e.target.value as LineUnit)}
|
||||
>
|
||||
{Object.entries(UNIT_LABELS).map(([value, text]) => (
|
||||
<SelectItem key={value} value={value} text={text} />
|
||||
))}
|
||||
</Select>
|
||||
</Column>
|
||||
) : null}
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<NumberInput
|
||||
id="ip-rate"
|
||||
label={editing.mode === "rate" ? "Rate (₹)" : "Amount (₹)"}
|
||||
min={0}
|
||||
step={1}
|
||||
value={paiseToRupees(editing.ratePaise)}
|
||||
onChange={(_e, { value }) => set("ratePaise", Math.max(0, rupeesToPaise(Number(value))))}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
danger
|
||||
open={deleting !== null}
|
||||
modalHeading="Delete item preset"
|
||||
primaryButtonText={busy ? "Deleting…" : "Delete"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={busy}
|
||||
onRequestClose={() => setDeleting(null)}
|
||||
onRequestSubmit={() => void remove()}
|
||||
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
|
||||
size="xs"
|
||||
>
|
||||
<p>
|
||||
Delete the preset “{deleting?.description}”? Invoices already created from it are not affected.
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createContext, useContext, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { ToastNotification } from "@carbon/react";
|
||||
import { createToastStore, type ToastStore } from "../lib/toastStore";
|
||||
|
||||
export interface ToastApi {
|
||||
success: (title: string, subtitle?: string) => number;
|
||||
info: (title: string, subtitle?: string) => number;
|
||||
warning: (title: string, subtitle?: string) => number;
|
||||
/** Errors stay until the user closes them. */
|
||||
error: (title: string, subtitle?: string) => number;
|
||||
dismiss: (id: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
function apiFor(store: ToastStore): ToastApi {
|
||||
return {
|
||||
success: (t, s) => store.add("success", t, s),
|
||||
info: (t, s) => store.add("info", t, s),
|
||||
warning: (t, s) => store.add("warning", t, s),
|
||||
error: (t, s) => store.add("error", t, s),
|
||||
dismiss: store.dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stacked top-right toasts. Place it inside the Carbon <Theme> so the tokens apply. */
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const storeRef = useRef<ToastStore | null>(null);
|
||||
if (!storeRef.current) storeRef.current = createToastStore();
|
||||
const store = storeRef.current;
|
||||
const toasts = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||
const api = useMemo(() => apiFor(store), [store]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
{/* The live region stays mounted so screen readers announce toasts as they are added. */}
|
||||
<div className="voiced-toasts" aria-live="polite" aria-relevant="additions" role="region" aria-label="Notifications">
|
||||
{toasts.map((t) => (
|
||||
<ToastNotification
|
||||
key={t.id}
|
||||
kind={t.kind}
|
||||
lowContrast
|
||||
title={t.title}
|
||||
subtitle={t.subtitle}
|
||||
role={t.kind === "error" ? "alert" : "status"}
|
||||
onClose={() => {
|
||||
store.dismiss(t.id);
|
||||
return false;
|
||||
}}
|
||||
onCloseButtonClick={() => store.dismiss(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const api = useContext(ToastContext);
|
||||
if (!api) throw new Error("useToast must be used inside <ToastProvider>");
|
||||
return api;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
InvoiceInput,
|
||||
InvoiceSeries,
|
||||
InvoiceSummary,
|
||||
ItemPreset,
|
||||
Settings,
|
||||
} from "./types";
|
||||
import type { LogoAsset } from "./logo";
|
||||
@@ -36,6 +37,10 @@ export const api = {
|
||||
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
|
||||
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
||||
|
||||
listItemPresets: () => invoke<ItemPreset[]>("list_item_presets"),
|
||||
saveItemPreset: (preset: ItemPreset) => invoke<ItemPreset>("save_item_preset", { preset }),
|
||||
deleteItemPreset: (id: number) => invoke<void>("delete_item_preset", { id }),
|
||||
|
||||
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
||||
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
|
||||
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { composeAddress, pincodeError, splitLegacyAddress } from "./clientAddress";
|
||||
|
||||
describe("composeAddress", () => {
|
||||
it("joins lines and the city/state/pincode line", () => {
|
||||
expect(
|
||||
composeAddress({ line1: "12 MG Road", line2: "Floor 2", city: "Bengaluru", stateCode: "29", pincode: "560001" }),
|
||||
).toBe("12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
|
||||
});
|
||||
it("skips empty parts", () => {
|
||||
expect(composeAddress({ line1: " A ", line2: "", city: "", stateCode: "", pincode: "560001" })).toBe("A\n560001");
|
||||
expect(composeAddress({ line1: "", line2: "", city: "Pune", stateCode: "", pincode: "" })).toBe("Pune");
|
||||
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "29", pincode: "" })).toBe("Karnataka");
|
||||
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "", pincode: "" })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitLegacyAddress", () => {
|
||||
it("puts the first line in line1 and the rest in line2", () => {
|
||||
expect(splitLegacyAddress("Old style\nfree text\r\nBangalore")).toEqual({
|
||||
line1: "Old style",
|
||||
line2: "free text, Bangalore",
|
||||
});
|
||||
expect(splitLegacyAddress("")).toEqual({ line1: "", line2: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pincodeError", () => {
|
||||
it("accepts empty and 6 digits only", () => {
|
||||
expect(pincodeError("")).toBeUndefined();
|
||||
expect(pincodeError("560001")).toBeUndefined();
|
||||
expect(pincodeError("56001")).toBeDefined();
|
||||
expect(pincodeError("56000A")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { INDIAN_STATES } from "./types";
|
||||
|
||||
export interface AddressParts {
|
||||
line1: string;
|
||||
line2: string;
|
||||
city: string;
|
||||
stateCode: string;
|
||||
pincode: string;
|
||||
}
|
||||
|
||||
export const EMPTY_ADDRESS: AddressParts = { line1: "", line2: "", city: "", stateCode: "", pincode: "" };
|
||||
|
||||
/**
|
||||
* The stored multi-line address: line 1, line 2, then "City, State - Pincode".
|
||||
* Mirrors `compose_address` in src-tauri/src/commands/clients.rs, which is authoritative on save.
|
||||
*/
|
||||
export function composeAddress(parts: AddressParts): string {
|
||||
const stateName = INDIAN_STATES.find((s) => s.code === parts.stateCode)?.name ?? "";
|
||||
const place = [parts.city.trim(), stateName].filter(Boolean).join(", ");
|
||||
const pin = parts.pincode.trim();
|
||||
const last = place && pin ? `${place} - ${pin}` : place || pin;
|
||||
return [parts.line1.trim(), parts.line2.trim(), last].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
/** Best effort for clients saved before structured fields existed: first line to line 1, the rest to line 2. */
|
||||
export function splitLegacyAddress(address: string): Pick<AddressParts, "line1" | "line2"> {
|
||||
const lines = address
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
return { line1: lines[0] ?? "", line2: lines.slice(1).join(", ") };
|
||||
}
|
||||
|
||||
export function isPincode(value: string): boolean {
|
||||
return /^[0-9]{6}$/.test(value.trim());
|
||||
}
|
||||
|
||||
export function pincodeError(value: string): string | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
return isPincode(value) ? undefined : "Pincode is 6 digits";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { itemFromPreset, paiseToRupees, rupeesToPaise } from "./presets";
|
||||
import { blankPreset } from "./types";
|
||||
|
||||
describe("preset money helpers", () => {
|
||||
it("rounds rupees to whole paise", () => {
|
||||
expect(rupeesToPaise(1500.5)).toBe(150050);
|
||||
expect(rupeesToPaise(0.1 + 0.2)).toBe(30);
|
||||
expect(rupeesToPaise(19.99)).toBe(1999);
|
||||
expect(rupeesToPaise(Number.NaN)).toBe(0);
|
||||
expect(paiseToRupees(150050)).toBe(1500.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("itemFromPreset", () => {
|
||||
it("fills a fixed line", () => {
|
||||
const item = itemFromPreset({ ...blankPreset(), description: "Retainer", mode: "fixed", ratePaise: 250000, hsnSac: "9983" }, { hsnSac: "0000" });
|
||||
expect(item).toMatchObject({ description: "Retainer", mode: "fixed", amount: 2500, rate: 0, quantity: 1, hsnSac: "9983" });
|
||||
});
|
||||
it("fills a rate line and falls back to the default HSN/SAC", () => {
|
||||
const item = itemFromPreset({ ...blankPreset(), description: "Hours", mode: "rate", unit: "hour", ratePaise: 150050 }, { hsnSac: "998397" });
|
||||
expect(item).toMatchObject({ mode: "rate", unit: "hour", rate: 1500.5, amount: 0, quantity: 1, hsnSac: "998397" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { InvoiceItem, ItemPreset } from "./types";
|
||||
|
||||
export const rupeesToPaise = (rupees: number): number => Math.round((Number(rupees) || 0) * 100);
|
||||
export const paiseToRupees = (paise: number): number => paise / 100;
|
||||
|
||||
/**
|
||||
* A new invoice line pre-filled from a preset. Invoice items store rupee floats, so the integer-paise
|
||||
* preset amount is converted only here.
|
||||
*/
|
||||
export function itemFromPreset(preset: ItemPreset, defaults: { hsnSac: string }): InvoiceItem {
|
||||
const rupees = paiseToRupees(preset.ratePaise);
|
||||
return {
|
||||
description: preset.description,
|
||||
mode: preset.mode,
|
||||
rate: preset.mode === "rate" ? rupees : 0,
|
||||
unit: preset.unit,
|
||||
quantity: 1,
|
||||
amount: preset.mode === "fixed" ? rupees : 0,
|
||||
sortOrder: 0,
|
||||
hsnSac: preset.hsnSac || defaults.hsnSac,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SETTINGS_TABS, loadSettingsTab, parseSettingsTab, saveSettingsTab } from "./settingsTab";
|
||||
|
||||
describe("settings tab persistence", () => {
|
||||
it("maps stored ids to indexes and falls back to the first tab", () => {
|
||||
expect(parseSettingsTab("fonts")).toBe(SETTINGS_TABS.findIndex((t) => t.id === "fonts"));
|
||||
expect(parseSettingsTab("nope")).toBe(0);
|
||||
expect(parseSettingsTab(null)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not throw when storage is unavailable", () => {
|
||||
// The node test environment has no localStorage at all.
|
||||
expect(loadSettingsTab()).toBe(0);
|
||||
expect(() => saveSettingsTab(2)).not.toThrow();
|
||||
expect(() => saveSettingsTab(99)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
const KEY = "voiced.settingsTab";
|
||||
|
||||
export const SETTINGS_TABS = [
|
||||
{ id: "business", label: "Business" },
|
||||
{ id: "branding", label: "Branding" },
|
||||
{ id: "bank", label: "Bank & payments" },
|
||||
{ id: "defaults", label: "Invoice defaults" },
|
||||
{ id: "numbering", label: "Numbering" },
|
||||
{ id: "templates", label: "Templates & export" },
|
||||
{ id: "fonts", label: "Fonts" },
|
||||
{ id: "data", label: "Data" },
|
||||
] as const;
|
||||
|
||||
export type SettingsTabId = (typeof SETTINGS_TABS)[number]["id"];
|
||||
|
||||
/** Index of a stored tab id; 0 (Business) for anything unknown. */
|
||||
export function parseSettingsTab(value: string | null | undefined): number {
|
||||
const index = SETTINGS_TABS.findIndex((t) => t.id === value);
|
||||
return index < 0 ? 0 : index;
|
||||
}
|
||||
|
||||
/** The last Settings tab this viewer used. A per-viewer convenience only. */
|
||||
export function loadSettingsTab(): number {
|
||||
try {
|
||||
return parseSettingsTab(localStorage.getItem(KEY));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettingsTab(index: number): void {
|
||||
try {
|
||||
const tab = SETTINGS_TABS[index];
|
||||
if (tab) localStorage.setItem(KEY, tab.id);
|
||||
} catch {
|
||||
// Not remembering is harmless.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AUTO_DISMISS_MS, MAX_TOASTS, createToastStore } from "./toastStore";
|
||||
|
||||
function fakeTimers() {
|
||||
let next = 1;
|
||||
const pending = new Map<number, { fn: () => void; ms: number }>();
|
||||
return {
|
||||
setTimer: (fn: () => void, ms: number) => {
|
||||
const id = next++;
|
||||
pending.set(id, { fn, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id: unknown) => void pending.delete(id as number),
|
||||
fireAll: () => [...pending.entries()].forEach(([id, t]) => (pending.delete(id), t.fn())),
|
||||
pending,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toast store", () => {
|
||||
it("auto-dismisses non-error toasts after the delay", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
store.add("success", "Saved");
|
||||
store.add("info", "FYI");
|
||||
expect([...timers.pending.values()].map((t) => t.ms)).toEqual([AUTO_DISMISS_MS, AUTO_DISMISS_MS]);
|
||||
timers.fireAll();
|
||||
expect(store.getSnapshot()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps errors until they are closed", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
const id = store.add("error", "Failed", "details");
|
||||
expect(timers.pending.size).toBe(0);
|
||||
timers.fireAll();
|
||||
expect(store.getSnapshot()).toEqual([{ id, kind: "error", title: "Failed", subtitle: "details" }]);
|
||||
store.dismiss(id);
|
||||
expect(store.getSnapshot()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("dismiss cancels the timer and is idempotent", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
const id = store.add("success", "x");
|
||||
store.dismiss(id);
|
||||
expect(timers.pending.size).toBe(0);
|
||||
store.dismiss(id);
|
||||
expect(store.getSnapshot()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("notifies subscribers with a new snapshot reference per change", () => {
|
||||
const store = createToastStore(fakeTimers());
|
||||
let calls = 0;
|
||||
const off = store.subscribe(() => calls++);
|
||||
const before = store.getSnapshot();
|
||||
store.add("info", "a");
|
||||
expect(calls).toBe(1);
|
||||
expect(store.getSnapshot()).not.toBe(before);
|
||||
const same = store.getSnapshot();
|
||||
store.dismiss(999);
|
||||
expect(calls).toBe(1);
|
||||
expect(store.getSnapshot()).toBe(same);
|
||||
off();
|
||||
store.add("info", "b");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("caps the stack and drops the oldest first, cancelling its timer", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
for (let i = 0; i < MAX_TOASTS + 2; i++) store.add("success", `t${i}`);
|
||||
const titles = store.getSnapshot().map((t) => t.title);
|
||||
expect(titles).toHaveLength(MAX_TOASTS);
|
||||
expect(titles[0]).toBe("t2");
|
||||
expect(timers.pending.size).toBe(MAX_TOASTS);
|
||||
});
|
||||
|
||||
it("clear empties the stack and cancels timers", () => {
|
||||
const timers = fakeTimers();
|
||||
const store = createToastStore(timers);
|
||||
store.add("success", "a");
|
||||
store.add("error", "b");
|
||||
store.clear();
|
||||
expect(store.getSnapshot()).toHaveLength(0);
|
||||
expect(timers.pending.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
export type ToastKind = "success" | "info" | "warning" | "error";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/** Success, info and warning toasts leave by themselves; errors stay until closed. */
|
||||
export const AUTO_DISMISS_MS = 5000;
|
||||
/** Oldest toasts are dropped beyond this, so a failing loop cannot fill the screen. */
|
||||
export const MAX_TOASTS = 5;
|
||||
|
||||
type TimerId = unknown;
|
||||
|
||||
export interface ToastStoreOptions {
|
||||
setTimer?: (fn: () => void, ms: number) => TimerId;
|
||||
clearTimer?: (id: TimerId) => void;
|
||||
}
|
||||
|
||||
export interface ToastStore {
|
||||
add: (kind: ToastKind, title: string, subtitle?: string) => number;
|
||||
dismiss: (id: number) => void;
|
||||
clear: () => void;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
getSnapshot: () => readonly Toast[];
|
||||
}
|
||||
|
||||
/** A tiny external store (useSyncExternalStore-compatible); the timers are injectable for tests. */
|
||||
export function createToastStore(options: ToastStoreOptions = {}): ToastStore {
|
||||
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
||||
const clearTimer = options.clearTimer ?? ((id) => clearTimeout(id as ReturnType<typeof setTimeout>));
|
||||
let toasts: readonly Toast[] = [];
|
||||
let nextId = 1;
|
||||
const timers = new Map<number, TimerId>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = () => listeners.forEach((l) => l());
|
||||
|
||||
const drop = (id: number) => {
|
||||
const timer = timers.get(id);
|
||||
if (timer !== undefined) {
|
||||
clearTimer(timer);
|
||||
timers.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = (id: number) => {
|
||||
drop(id);
|
||||
if (!toasts.some((t) => t.id === id)) return;
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
emit();
|
||||
};
|
||||
|
||||
return {
|
||||
add(kind, title, subtitle) {
|
||||
const id = nextId++;
|
||||
toasts = [...toasts, { id, kind, title, subtitle }];
|
||||
while (toasts.length > MAX_TOASTS) {
|
||||
drop(toasts[0].id);
|
||||
toasts = toasts.slice(1);
|
||||
}
|
||||
if (kind !== "error") timers.set(id, setTimer(() => dismiss(id), AUTO_DISMISS_MS));
|
||||
emit();
|
||||
return id;
|
||||
},
|
||||
dismiss,
|
||||
clear() {
|
||||
timers.forEach((t) => clearTimer(t));
|
||||
timers.clear();
|
||||
toasts = [];
|
||||
emit();
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getSnapshot: () => toasts,
|
||||
};
|
||||
}
|
||||
@@ -58,16 +58,80 @@ export interface InvoiceSeries {
|
||||
nextInvoiceNumber: string;
|
||||
}
|
||||
|
||||
export type GstCategory = "registered_regular" | "unregistered" | "composition" | "sez" | "overseas";
|
||||
|
||||
/** Informational only; the tax heads still derive from supplier state and place of supply. */
|
||||
export const GST_CATEGORY_LABELS: Record<GstCategory, string> = {
|
||||
registered_regular: "Registered (regular)",
|
||||
unregistered: "Unregistered",
|
||||
composition: "Composition",
|
||||
sez: "SEZ",
|
||||
overseas: "Overseas",
|
||||
};
|
||||
|
||||
export interface Client {
|
||||
id: number | null;
|
||||
name: string;
|
||||
/** The composed multi-line address; what invoices store and print. */
|
||||
address: string;
|
||||
gstin: string;
|
||||
/** Doubles as the address state and the default place of supply. */
|
||||
stateCode: string;
|
||||
poNumber: string;
|
||||
createdAt: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
city: string;
|
||||
pincode: string;
|
||||
gstCategory: GstCategory;
|
||||
/** Notes / payment terms text copied onto a new invoice when the client is picked. */
|
||||
defaultNotes: string;
|
||||
/** Null means the settings default. */
|
||||
paymentTermsDays: number | null;
|
||||
/** Read-only: how many invoices reference this client. */
|
||||
invoiceCount?: number;
|
||||
}
|
||||
|
||||
export const blankClient = (): Client => ({
|
||||
id: null,
|
||||
name: "",
|
||||
address: "",
|
||||
gstin: "",
|
||||
stateCode: "",
|
||||
poNumber: "",
|
||||
createdAt: "",
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
city: "",
|
||||
pincode: "",
|
||||
gstCategory: "unregistered",
|
||||
defaultNotes: "",
|
||||
paymentTermsDays: null,
|
||||
});
|
||||
|
||||
/** A saved line item. `ratePaise` is the amount for "fixed" and the per-unit rate for "rate". */
|
||||
export interface ItemPreset {
|
||||
id: number | null;
|
||||
description: string;
|
||||
hsnSac: string;
|
||||
mode: LineMode;
|
||||
unit: LineUnit;
|
||||
ratePaise: number;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const blankPreset = (): ItemPreset => ({
|
||||
id: null,
|
||||
description: "",
|
||||
hsnSac: "",
|
||||
mode: "fixed",
|
||||
unit: "unit",
|
||||
ratePaise: 0,
|
||||
sortOrder: 0,
|
||||
createdAt: "",
|
||||
});
|
||||
|
||||
export interface InvoiceItem {
|
||||
id?: number | null;
|
||||
description: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
|
||||
import { clientGstinStateError, gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
|
||||
|
||||
// Same GSTINs as the Rust tests in src-tauri/src/gst.rs.
|
||||
describe("GSTIN checksum", () => {
|
||||
@@ -25,3 +25,19 @@ describe("GSTIN checksum", () => {
|
||||
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "27")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientGstinStateError", () => {
|
||||
it("allows empty and NA", () => {
|
||||
expect(clientGstinStateError("", "29")).toBeUndefined();
|
||||
expect(clientGstinStateError("NA", "29")).toBeUndefined();
|
||||
});
|
||||
it("checks shape and checksum", () => {
|
||||
expect(clientGstinStateError("29ABCDE1234F1Z5", "29")).toMatch(/check digit/);
|
||||
expect(clientGstinStateError("29ABC", "")).toMatch(/15-character/);
|
||||
});
|
||||
it("checks the state prefix only when a state is chosen", () => {
|
||||
expect(clientGstinStateError("29ABCDE1234F1ZW", "29")).toBeUndefined();
|
||||
expect(clientGstinStateError("29abcde1234f1zw", "")).toBeUndefined();
|
||||
expect(clientGstinStateError("29ABCDE1234F1ZW", "27")).toMatch(/starts with 29/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,3 +84,18 @@ export function accountError(value: string): string | undefined {
|
||||
export const isPan = (v: string) => PAN_RE.test(norm(v));
|
||||
export const isIfsc = (v: string) => IFSC_RE.test(norm(v));
|
||||
export const isGstin = (v: string) => GSTIN_RE.test(norm(v));
|
||||
|
||||
/**
|
||||
* Client GSTIN rules: shape, check digit, and that its state prefix matches the client's state when both
|
||||
* are given. Empty (or "NA") is valid; a client may be unregistered.
|
||||
*/
|
||||
export function clientGstinStateError(gstin: string, stateCode: string): string | undefined {
|
||||
const g = norm(gstin);
|
||||
if (!g || g === "NA") return undefined;
|
||||
const base = gstinFullError(g);
|
||||
if (base) return base;
|
||||
if (stateCode && g.slice(0, 2) !== stateCode) {
|
||||
return `GSTIN starts with ${g.slice(0, 2)}, but the state is ${stateCode}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -148,6 +148,23 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.voiced-toasts {
|
||||
position: fixed;
|
||||
top: 3.5rem; /* below the 3rem Carbon header */
|
||||
right: 1rem;
|
||||
z-index: 9100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: calc(100vw - 2rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.voiced-toasts .cds--toast-notification {
|
||||
pointer-events: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.voiced-page {
|
||||
padding: 2rem;
|
||||
max-width: 1600px;
|
||||
|
||||
+133
-101
@@ -3,7 +3,6 @@ import {
|
||||
Button,
|
||||
Column,
|
||||
Grid,
|
||||
InlineNotification,
|
||||
Link,
|
||||
NumberInput,
|
||||
RadioButton,
|
||||
@@ -19,6 +18,11 @@ import {
|
||||
TableRow,
|
||||
TextArea,
|
||||
TextInput,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
Tile,
|
||||
Toggle,
|
||||
} from "@carbon/react";
|
||||
@@ -37,6 +41,9 @@ import { ImagePicker } from "../components/ImagePicker";
|
||||
import { LogoBranding } from "../components/LogoBranding";
|
||||
import DiagnosticsPanel from "../components/DiagnosticsPanel";
|
||||
import { FontOverrideTable } from "../components/FontOverrideTable";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
|
||||
import SeriesSettings from "./SeriesSettings";
|
||||
|
||||
const blankBank = (): BankAccount => ({
|
||||
id: null,
|
||||
@@ -61,14 +68,20 @@ export default function AppSettings({
|
||||
const [banks, setBanks] = useState<BankAccount[]>([]);
|
||||
const [editing, setEditing] = useState<BankAccount | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const toast = useToast();
|
||||
// One form state for every tab and one Save button, so edits survive switching tabs.
|
||||
const [tab, setTab] = useState(loadSettingsTab);
|
||||
|
||||
const selectTab = (index: number) => {
|
||||
setTab(index);
|
||||
saveSettingsTab(index);
|
||||
};
|
||||
|
||||
const loadBanks = async () => {
|
||||
try {
|
||||
setBanks(await api.listBanks());
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not load bank accounts", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,18 +105,17 @@ export default function AppSettings({
|
||||
|
||||
const saveAll = async () => {
|
||||
if (gstinProblem) {
|
||||
setError(gstinProblem);
|
||||
selectTab(0);
|
||||
toast.error("Settings not saved", gstinProblem);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.saveSettings(form);
|
||||
setNotice("Settings saved.");
|
||||
toast.success("Settings saved");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not save settings", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -112,7 +124,6 @@ export default function AppSettings({
|
||||
const saveBank = async () => {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await api.saveBank(editing);
|
||||
if (saved.isDefault || form.defaultBankId === null) {
|
||||
@@ -120,8 +131,9 @@ export default function AppSettings({
|
||||
}
|
||||
setEditing(null);
|
||||
await loadBanks();
|
||||
toast.success("Bank account saved", "Click Save settings to keep a changed default account.");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not save bank account", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -139,30 +151,29 @@ export default function AppSettings({
|
||||
if (form.defaultBankId === bank.id) set("defaultBankId", null);
|
||||
await loadBanks();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not delete bank account", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<h1 className="voiced-page__title">Settings</h1>
|
||||
<p className="voiced-page__subtitle">Vendor details, defaults and bank accounts.</p>
|
||||
|
||||
{error ? (
|
||||
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
||||
) : null}
|
||||
{notice ? (
|
||||
<InlineNotification
|
||||
kind="success"
|
||||
lowContrast
|
||||
title="Saved"
|
||||
subtitle={notice}
|
||||
onCloseButtonClick={() => setNotice(null)}
|
||||
/>
|
||||
) : null}
|
||||
<p className="voiced-page__subtitle">Business details, branding, defaults and bank accounts.</p>
|
||||
|
||||
<Tabs selectedIndex={tab} onChange={({ selectedIndex }) => selectTab(selectedIndex)}>
|
||||
<TabList aria-label="Settings sections" contained scrollIntoView>
|
||||
<Tab>Business</Tab>
|
||||
<Tab>Branding</Tab>
|
||||
<Tab>Bank & payments</Tab>
|
||||
<Tab>Invoice defaults</Tab>
|
||||
<Tab>Numbering</Tab>
|
||||
<Tab>Templates & export</Tab>
|
||||
<Tab>Fonts</Tab>
|
||||
<Tab>Data</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Vendor details</h3>
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<RadioButtonGroup
|
||||
@@ -277,9 +288,9 @@ export default function AppSettings({
|
||||
</Column>
|
||||
</Grid>
|
||||
</section>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Branding</h3>
|
||||
<Tile>
|
||||
<LogoBranding
|
||||
idPrefix="s"
|
||||
@@ -289,78 +300,8 @@ export default function AppSettings({
|
||||
/>
|
||||
</Tile>
|
||||
</section>
|
||||
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Fonts</h3>
|
||||
<Tile>
|
||||
<FontOverrideTable />
|
||||
</Tile>
|
||||
</section>
|
||||
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Defaults</h3>
|
||||
<Grid narrow>
|
||||
{registered ? (
|
||||
<>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-taxRate"
|
||||
label="Default tax rate (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={form.defaultTaxRate}
|
||||
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput
|
||||
id="s-hsn"
|
||||
labelText="Default HSN/SAC"
|
||||
value={form.defaultHsnSac}
|
||||
onChange={(e) => set("defaultHsnSac", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
</>
|
||||
) : null}
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-terms"
|
||||
label="Payment terms (days)"
|
||||
min={0}
|
||||
max={365}
|
||||
value={form.paymentTermsDays}
|
||||
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={6}>
|
||||
<Select
|
||||
id="s-theme"
|
||||
labelText="Theme"
|
||||
value={form.theme}
|
||||
onChange={(e) => set("theme", e.target.value)}
|
||||
>
|
||||
<SelectItem value="g10" text="Carbon light (g10)" />
|
||||
<SelectItem value="g100" text="Carbon dark (g100)" />
|
||||
</Select>
|
||||
</Column>
|
||||
{registered ? (
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<RadioButtonGroup
|
||||
legendText="Default tax treatment"
|
||||
name="s-taxType"
|
||||
valueSelected={form.defaultTaxType}
|
||||
onChange={(value) => set("defaultTaxType", value as TaxType)}
|
||||
>
|
||||
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
|
||||
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
|
||||
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
) : null}
|
||||
</Grid>
|
||||
</section>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h3>Bank accounts</h3>
|
||||
@@ -487,6 +428,97 @@ export default function AppSettings({
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<Grid narrow>
|
||||
{registered ? (
|
||||
<>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-taxRate"
|
||||
label="Default tax rate (%)"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={form.defaultTaxRate}
|
||||
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<TextInput
|
||||
id="s-hsn"
|
||||
labelText="Default HSN/SAC"
|
||||
value={form.defaultHsnSac}
|
||||
onChange={(e) => set("defaultHsnSac", e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
</>
|
||||
) : null}
|
||||
<Column sm={4} md={4} lg={5}>
|
||||
<NumberInput
|
||||
id="s-terms"
|
||||
label="Payment terms (days)"
|
||||
min={0}
|
||||
max={365}
|
||||
value={form.paymentTermsDays}
|
||||
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
|
||||
/>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={6}>
|
||||
<Select
|
||||
id="s-theme"
|
||||
labelText="Theme"
|
||||
value={form.theme}
|
||||
onChange={(e) => set("theme", e.target.value)}
|
||||
>
|
||||
<SelectItem value="g10" text="Carbon light (g10)" />
|
||||
<SelectItem value="g100" text="Carbon dark (g100)" />
|
||||
</Select>
|
||||
</Column>
|
||||
{registered ? (
|
||||
<Column sm={4} md={8} lg={16}>
|
||||
<RadioButtonGroup
|
||||
legendText="Default tax treatment"
|
||||
name="s-taxType"
|
||||
valueSelected={form.defaultTaxType}
|
||||
onChange={(value) => set("defaultTaxType", value as TaxType)}
|
||||
>
|
||||
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
|
||||
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
|
||||
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
|
||||
</RadioButtonGroup>
|
||||
</Column>
|
||||
) : null}
|
||||
</Grid>
|
||||
</section>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<SeriesSettings embedded active={tab === SETTINGS_TABS.findIndex((t) => t.id === "numbering")} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack">
|
||||
<p>
|
||||
The template, page size and margins are chosen next to the preview on the New invoice screen. The
|
||||
default page setup is saved from there.
|
||||
</p>
|
||||
<p className="voiced-muted">Export mode (searchable or flattened) is chosen when you issue an invoice.</p>
|
||||
</section>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<Tile>
|
||||
<FontOverrideTable />
|
||||
</Tile>
|
||||
</section>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack">
|
||||
<p>Backup and export arrive in the next step.</p>
|
||||
</section>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<Button renderIcon={Save} onClick={saveAll} disabled={busy}>
|
||||
{busy ? "Saving…" : "Save settings"}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
DataTable,
|
||||
InlineLoading,
|
||||
Modal,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableToolbar,
|
||||
TableToolbarContent,
|
||||
TableToolbarSearch,
|
||||
Tabs,
|
||||
Tag,
|
||||
} from "@carbon/react";
|
||||
import { Add, Edit, TrashCan } from "@carbon/icons-react";
|
||||
import { api } from "../lib/api";
|
||||
import { GST_CATEGORY_LABELS, INDIAN_STATES, type Client } from "../lib/types";
|
||||
import ClientModal from "../components/ClientModal";
|
||||
import ItemPresetsPanel from "../components/ItemPresetsPanel";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
|
||||
const HEADERS = [
|
||||
{ key: "name", header: "Name" },
|
||||
{ key: "gstin", header: "GSTIN" },
|
||||
{ key: "category", header: "GST category" },
|
||||
{ key: "place", header: "City / State" },
|
||||
{ key: "invoices", header: "Invoices" },
|
||||
{ key: "actions", header: "" },
|
||||
];
|
||||
|
||||
const stateName = (code: string) => INDIAN_STATES.find((s) => s.code === code)?.name ?? "";
|
||||
|
||||
export default function Clients({ active }: { active: boolean }) {
|
||||
const toast = useToast();
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<Client | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState<Client | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const launcher = useRef<HTMLElement | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setClients(await api.listClients());
|
||||
} catch (e) {
|
||||
toast.error("Could not load clients", String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
// The view stays mounted, so refetch whenever it is shown again.
|
||||
useEffect(() => {
|
||||
if (active) void load();
|
||||
}, [active, load]);
|
||||
|
||||
const openEditor = (client: Client | null, trigger: HTMLElement) => {
|
||||
launcher.current = trigger;
|
||||
setEditing(client);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleting || deleting.id === null) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteClient(deleting.id);
|
||||
toast.success("Client deleted", deleting.name);
|
||||
setDeleting(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error("Could not delete client", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const byId = new Map(clients.map((c) => [String(c.id), c]));
|
||||
const rows = clients.map((c) => ({
|
||||
id: String(c.id),
|
||||
name: c.name,
|
||||
gstin: c.gstin || "—",
|
||||
category: GST_CATEGORY_LABELS[c.gstCategory] ?? c.gstCategory,
|
||||
place: [c.city, stateName(c.stateCode)].filter(Boolean).join(", ") || "—",
|
||||
invoices: c.invoiceCount ?? 0,
|
||||
actions: "",
|
||||
}));
|
||||
|
||||
const clientsTable = loading ? (
|
||||
<InlineLoading description="Loading clients" />
|
||||
) : (
|
||||
<DataTable rows={rows} headers={HEADERS} isSortable>
|
||||
{({ rows: tableRows, headers, getHeaderProps, getRowProps, getTableProps, onInputChange }) => (
|
||||
<TableContainer>
|
||||
<TableToolbar>
|
||||
<TableToolbarContent>
|
||||
<TableToolbarSearch
|
||||
persistent
|
||||
placeholder="Search clients"
|
||||
labelText="Search clients"
|
||||
onChange={(e) => onInputChange(e as React.ChangeEvent<HTMLInputElement>)}
|
||||
/>
|
||||
<Button
|
||||
renderIcon={Add}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(null, e.currentTarget)}
|
||||
>
|
||||
Add client
|
||||
</Button>
|
||||
</TableToolbarContent>
|
||||
</TableToolbar>
|
||||
<Table {...getTableProps()} aria-label="Clients">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headers.map((header) => {
|
||||
const { key, ...headerProps } = getHeaderProps({ header });
|
||||
return header.key === "actions" ? (
|
||||
<TableHeader key={key} aria-label="Actions" />
|
||||
) : (
|
||||
<TableHeader key={key} {...headerProps}>
|
||||
{header.header}
|
||||
</TableHeader>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tableRows.map((row) => {
|
||||
const client = byId.get(row.id);
|
||||
const { key, ...rowProps } = getRowProps({ row });
|
||||
return (
|
||||
<TableRow key={key} {...rowProps}>
|
||||
{row.cells.map((cell) => {
|
||||
if (cell.info.header === "actions") {
|
||||
return (
|
||||
<TableCell key={cell.id}>
|
||||
{client ? (
|
||||
<div className="voiced-header-action">
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Edit}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(client, e.currentTarget)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={TrashCan}
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => {
|
||||
launcher.current = e.currentTarget;
|
||||
setDeleting(client);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (cell.info.header === "category" && client) {
|
||||
return (
|
||||
<TableCell key={cell.id}>
|
||||
<Tag type={client.gstCategory === "unregistered" ? "gray" : "blue"} size="sm">
|
||||
{cell.value}
|
||||
</Tag>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
return <TableCell key={cell.id}>{cell.value}</TableCell>;
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{clients.length === 0 ? <div className="voiced-empty">No clients yet. Add one to reuse it on invoices.</div> : null}
|
||||
</TableContainer>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
|
||||
const count = deleting?.invoiceCount ?? 0;
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<h1 className="voiced-page__title">Clients</h1>
|
||||
<p className="voiced-page__subtitle">Saved clients and item presets for faster invoicing.</p>
|
||||
|
||||
<Tabs>
|
||||
<TabList aria-label="Clients and presets" contained>
|
||||
<Tab>Clients</Tab>
|
||||
<Tab>Item presets</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>{clientsTable}</TabPanel>
|
||||
<TabPanel>
|
||||
<ItemPresetsPanel active={active} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<ClientModal
|
||||
open={modalOpen}
|
||||
client={editing}
|
||||
launcherRef={launcher}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={() => {
|
||||
setModalOpen(false);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
danger
|
||||
open={deleting !== null}
|
||||
modalHeading="Delete client"
|
||||
primaryButtonText={busy ? "Deleting…" : "Delete"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={busy}
|
||||
onRequestClose={() => setDeleting(null)}
|
||||
onRequestSubmit={() => void confirmDelete()}
|
||||
launcherButtonRef={launcher}
|
||||
size="xs"
|
||||
>
|
||||
<p>
|
||||
Delete “{deleting?.name}”?{" "}
|
||||
{count > 0
|
||||
? `${count} invoice${count === 1 ? "" : "s"} refer${count === 1 ? "s" : ""} to this client. They keep their own copy of the client's name, address and GSTIN, so they are not changed.`
|
||||
: "No invoices refer to this client."}
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
Modal,
|
||||
OverflowMenu,
|
||||
OverflowMenuItem,
|
||||
@@ -24,17 +23,17 @@ import type { ExportMode } from "../lib/filename";
|
||||
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
||||
import ExportFeedback from "../components/ExportFeedback";
|
||||
import type { InvoiceSummary, Settings } from "../lib/types";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
|
||||
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
|
||||
const toast = useToast();
|
||||
const [rows, setRows] = useState<InvoiceSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
// Whether each invoice has an archived PDF; unknown (absent) until its status was read.
|
||||
const [archived, setArchived] = useState<Record<number, boolean>>({});
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const exp = useInvoiceExport({
|
||||
settings,
|
||||
bank: null,
|
||||
@@ -44,9 +43,8 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
||||
const load = async () => {
|
||||
try {
|
||||
setRows(await api.listInvoices());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not load invoices", String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -78,14 +76,12 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
||||
|
||||
const onExport = async (id: number, mode: ExportMode, source: ExportSource) => {
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
// Fetched now, not at list time: the row summary does not carry the archive fields.
|
||||
const invoice = await api.getInvoice(id);
|
||||
await exp.start(invoice, mode, source);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Export failed", String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -93,19 +89,17 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
||||
|
||||
const onArchive = async (id: number) => {
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const invoice = await api.getInvoice(id);
|
||||
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
|
||||
if (outcome.archived) {
|
||||
setArchived((a) => ({ ...a, [id]: true }));
|
||||
setNotice(`Invoice ${invoice.number} is archived.`);
|
||||
toast.success(`Invoice ${invoice.number} is archived`);
|
||||
} else {
|
||||
setError(`Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
|
||||
toast.error("Could not archive the PDF", `Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not archive the PDF", String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -115,12 +109,13 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
||||
if (!cancelTarget) return;
|
||||
try {
|
||||
await api.cancelInvoice(cancelTarget.id, reason);
|
||||
toast.success(`Invoice ${cancelTarget.number} cancelled`);
|
||||
setCancelTarget(null);
|
||||
setReason("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setCancelTarget(null);
|
||||
setError(String(e));
|
||||
toast.error("Could not cancel the invoice", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -129,11 +124,6 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
||||
<h1 className="voiced-page__title">Invoices</h1>
|
||||
<p className="voiced-page__subtitle">Every invoice you have issued, newest first.</p>
|
||||
|
||||
{error ? (
|
||||
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
||||
) : null}
|
||||
|
||||
{notice ? <InlineNotification kind="success" lowContrast title={notice} onCloseButtonClick={() => setNotice(null)} /> : null}
|
||||
<ExportFeedback exp={exp} />
|
||||
|
||||
{loading ? (
|
||||
|
||||
+79
-18
@@ -2,6 +2,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } fro
|
||||
import {
|
||||
ActionableNotification,
|
||||
Button,
|
||||
ComboBox,
|
||||
Column,
|
||||
Dropdown,
|
||||
Grid,
|
||||
@@ -37,6 +38,7 @@ import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
|
||||
import type {
|
||||
BankAccount,
|
||||
Client,
|
||||
ItemPreset,
|
||||
DraftSummary,
|
||||
Invoice,
|
||||
InvoiceInput,
|
||||
@@ -68,6 +70,9 @@ import { useUserFonts } from "../hooks/useUserFonts";
|
||||
import { withUserFonts } from "../lib/fontImport";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
import { loadLogoImages, type LogoImages } from "../lib/logo";
|
||||
import ClientModal from "../components/ClientModal";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import { itemFromPreset } from "../lib/presets";
|
||||
|
||||
// pdf.js is only needed once a preview exists, so it loads on demand.
|
||||
const PdfPreview = lazy(() => import("../components/PdfPreview"));
|
||||
@@ -90,6 +95,7 @@ const formatStamp = (iso: string) => {
|
||||
};
|
||||
|
||||
export default function NewInvoice({ settings, onSettingsChange, active, onActivate }: NewInvoiceProps) {
|
||||
const toast = useToast();
|
||||
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
||||
const blankState = (): InvoiceFormState => {
|
||||
const t = todayIso();
|
||||
@@ -118,6 +124,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
const [number, setNumber] = useState("");
|
||||
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [presets, setPresets] = useState<ItemPreset[]>([]);
|
||||
const [clientModalOpen, setClientModalOpen] = useState(false);
|
||||
const newClientButton = useRef<HTMLButtonElement | null>(null);
|
||||
const [clientId, setClientId] = useState<number | null>(null);
|
||||
const [clientName, setClientName] = useState("");
|
||||
const [clientAddress, setClientAddress] = useState("");
|
||||
@@ -188,7 +197,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
setDrafts(draftList);
|
||||
setNoticeOpen(draftList.length > 0);
|
||||
} catch (e) {
|
||||
if (active) setError(String(e));
|
||||
if (active) toast.error("Could not load invoice data", String(e));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -196,6 +205,13 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The view stays mounted, so pick up clients and presets edited on the Clients page when it is shown again.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
api.listClients().then(setClients).catch(() => {});
|
||||
api.listItemPresets().then(setPresets).catch(() => {});
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!settings.logoPath) {
|
||||
@@ -333,6 +349,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
// A failed autosave is reported once per failure streak (the message clears on the next success).
|
||||
useEffect(() => {
|
||||
if (draftError) toast.warning("Draft not saved", draftError);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draftError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!unsaved) return;
|
||||
const timer = setTimeout(() => {
|
||||
@@ -390,6 +412,15 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
setClientGstin(selected.gstin);
|
||||
setPoNumber(selected.poNumber);
|
||||
if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
|
||||
// Client defaults fill gaps only: typed notes are never overwritten.
|
||||
if (selected.defaultNotes && !notes.trim()) setNotes(selected.defaultNotes);
|
||||
if (selected.paymentTermsDays !== null) setDueDate(addDays(invoiceDate, selected.paymentTermsDays));
|
||||
};
|
||||
|
||||
const addPreset = (preset: ItemPreset) => {
|
||||
const line = itemFromPreset(preset, { hsnSac: settings.defaultHsnSac });
|
||||
const isBlank = (i: InvoiceItem) => !i.description.trim() && !i.amount && !i.rate;
|
||||
setItems((prev) => (prev.length === 1 && isBlank(prev[0]) ? [line] : [...prev, line]));
|
||||
};
|
||||
|
||||
const applyState = (s: InvoiceFormState) => {
|
||||
@@ -527,7 +558,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
void refreshNumber();
|
||||
void refreshDrafts();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not open the draft", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -535,7 +566,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
try {
|
||||
await api.deleteDraft(id);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not delete the draft", String(e));
|
||||
}
|
||||
await refreshDrafts();
|
||||
};
|
||||
@@ -596,7 +627,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank }));
|
||||
} catch (e) {
|
||||
issuedRef.current = false;
|
||||
setError(String(e));
|
||||
toast.error("Could not issue the invoice", String(e));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
@@ -681,20 +712,11 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
<InlineNotification
|
||||
kind="error"
|
||||
lowContrast
|
||||
title="Could not save invoice"
|
||||
title="Fix this before issuing"
|
||||
subtitle={error}
|
||||
onCloseButtonClick={() => setError(null)}
|
||||
/>
|
||||
) : null}
|
||||
{draftError ? (
|
||||
<InlineNotification
|
||||
kind="warning"
|
||||
lowContrast
|
||||
title="Draft not saved"
|
||||
subtitle={draftError}
|
||||
onCloseButtonClick={() => setDraftError(null)}
|
||||
/>
|
||||
) : null}
|
||||
{!locked && noticeOpen && !dirty && otherDrafts.length > 0 ? (
|
||||
<ActionableNotification
|
||||
inline
|
||||
@@ -766,16 +788,30 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
</Column>
|
||||
|
||||
<Column sm={4} md={8} lg={8}>
|
||||
<Dropdown
|
||||
<ComboBox
|
||||
id="savedClient"
|
||||
disabled={locked}
|
||||
titleText="Saved client (optional)"
|
||||
label="Select a saved client to autofill"
|
||||
placeholder="Type to search saved clients"
|
||||
items={clients}
|
||||
itemToString={(item) => (item ? item.name : "")}
|
||||
itemToString={(item: Client | null) => (item ? item.name : "")}
|
||||
selectedItem={clients.find((c) => c.id === clientId) ?? null}
|
||||
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
|
||||
shouldFilterItem={({ item, inputValue }: { item: Client; inputValue: string | null }) => {
|
||||
const q = (inputValue ?? "").trim().toLowerCase();
|
||||
return !q || item.name.toLowerCase().includes(q) || item.gstin.toLowerCase().includes(q);
|
||||
}}
|
||||
onChange={({ selectedItem }: { selectedItem?: Client | null }) => selectedItem && applyClient(selectedItem)}
|
||||
/>
|
||||
<Button
|
||||
ref={newClientButton}
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
renderIcon={Add}
|
||||
disabled={locked}
|
||||
onClick={() => setClientModalOpen(true)}
|
||||
>
|
||||
Create client
|
||||
</Button>
|
||||
</Column>
|
||||
<Column sm={4} md={8} lg={8}>
|
||||
<TextInput
|
||||
@@ -845,6 +881,20 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
|
||||
<div>
|
||||
<h3 style={{ marginBottom: "0.75rem" }}>Line items</h3>
|
||||
{presets.length > 0 ? (
|
||||
<div style={{ maxWidth: "24rem", marginBottom: "0.75rem" }}>
|
||||
<Dropdown
|
||||
id="addFromPreset"
|
||||
disabled={locked}
|
||||
titleText="Add from preset"
|
||||
label="Choose a saved item"
|
||||
items={presets}
|
||||
itemToString={(item: ItemPreset | null) => (item ? item.description : "")}
|
||||
selectedItem={null}
|
||||
onChange={({ selectedItem }: { selectedItem?: ItemPreset | null }) => selectedItem && addPreset(selectedItem)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
@@ -1134,6 +1184,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Modal>
|
||||
<ClientModal
|
||||
open={clientModalOpen}
|
||||
client={null}
|
||||
launcherRef={newClientButton}
|
||||
onClose={() => setClientModalOpen(false)}
|
||||
onSaved={(saved) => {
|
||||
setClientModalOpen(false);
|
||||
applyClient(saved);
|
||||
api.listClients().then(setClients).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Column,
|
||||
Grid,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
NumberInput,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,8 +18,17 @@ import {
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import type { InvoiceSeries } from "../lib/types";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
|
||||
export default function SeriesSettings({ active: visible }: { active: boolean }) {
|
||||
export default function SeriesSettings({
|
||||
active: visible,
|
||||
embedded = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
/** Rendered inside Settings: no page wrapper or title. */
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [active, setActive] = useState<InvoiceSeries | null>(null);
|
||||
const [all, setAll] = useState<InvoiceSeries[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -28,8 +36,6 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
|
||||
const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`);
|
||||
const [padding, setPadding] = useState(3);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const seeded = useRef(false);
|
||||
|
||||
@@ -44,9 +50,8 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
|
||||
setPadding(current.padding);
|
||||
seeded.current = true;
|
||||
}
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not load series", String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -65,47 +70,34 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
|
||||
);
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const created = await api.startNewSeries(prefix, padding);
|
||||
setNotice(`New series started. Next invoice: ${created.nextInvoiceNumber}`);
|
||||
toast.success("New series started", `Next invoice: ${created.nextInvoiceNumber}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not start a new series", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pageClass = embedded ? undefined : "voiced-page";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<div className={pageClass}>
|
||||
<InlineLoading description="Loading series" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<h1 className="voiced-page__title">Invoice series</h1>
|
||||
<p className="voiced-page__subtitle">
|
||||
<div className={pageClass}>
|
||||
{embedded ? null : <h1 className="voiced-page__title">Invoice series</h1>}
|
||||
<p className={embedded ? "voiced-muted" : "voiced-page__subtitle"}>
|
||||
The active series drives the automatic invoice numbers. Start a new series to reset the counter.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
||||
) : null}
|
||||
{notice ? (
|
||||
<InlineNotification
|
||||
kind="success"
|
||||
lowContrast
|
||||
title="Done"
|
||||
subtitle={notice}
|
||||
onCloseButtonClick={() => setNotice(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Grid narrow style={{ marginBottom: "2rem" }}>
|
||||
<Column sm={4} md={4} lg={6}>
|
||||
<TextInput
|
||||
|
||||
Reference in New Issue
Block a user