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