Files
Voiced/src/components/ClientModal.tsx
T
xavierk 3510cc4260 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.
2026-10-04 17:29:46 +05:30

225 lines
7.9 KiB
TypeScript

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>
);
}