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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user