From 0aca8b42b450eafeb0178161db3dec8f027942aa Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 13:21:23 +0530 Subject: [PATCH] =?UTF-8?q?feat(hq-web):=20client=20create/edit=20dialog?= =?UTF-8?q?=20with=20contacts=20repeater=20=E2=80=94=20clients=20finally?= =?UTF-8?q?=20editable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/ClientFormDialog.tsx | 106 ++++++++++++++++++ apps/hq-web/src/pages/ClientDetail.tsx | 9 ++ apps/hq-web/src/pages/Clients.tsx | 55 ++------- 3 files changed, 123 insertions(+), 47 deletions(-) create mode 100644 apps/hq-web/src/components/ClientFormDialog.tsx diff --git a/apps/hq-web/src/components/ClientFormDialog.tsx b/apps/hq-web/src/components/ClientFormDialog.tsx new file mode 100644 index 0000000..663a5d4 --- /dev/null +++ b/apps/hq-web/src/components/ClientFormDialog.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react' +import { Button, Dialog, FormField, FormGrid, Notice, useToast } from '@sims/ui' +import { createClient, patchClient, type Client, type ClientContact } from '../api' + +interface ContactDraft { name: string; email: string; phone: string; role: string } +const EMPTY_CONTACT: ContactDraft = { name: '', email: '', phone: '', role: '' } + +/** Create/edit a client — identity, address, notes, and the contacts repeater. */ +export function ClientFormDialog(props: { + open: boolean + onClose: () => void + initial?: Client + onSaved: (c: Client) => void +}) { + const toast = useToast() + const editing = props.initial !== undefined + const [f, setF] = useState({ name: '', gstin: '', stateCode: '32', address: '', notes: '' }) + const [contacts, setContacts] = useState([]) + const [error, setError] = useState() + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!props.open) return + const c = props.initial + setError(undefined) + setF({ + name: c?.name ?? '', gstin: c?.gstin ?? '', stateCode: c?.stateCode ?? '32', + address: c?.address ?? '', notes: c?.notes ?? '', + }) + setContacts((c?.contacts ?? []).map((ct) => ({ + name: ct.name, email: ct.email ?? '', phone: ct.phone ?? '', role: ct.role ?? '', + }))) + }, [props.open, props.initial]) + + const set = (k: keyof typeof f) => (e: { target: { value: string } }) => + setF((p) => ({ ...p, [k]: e.target.value })) + const setContact = (i: number, k: keyof ContactDraft) => (e: { target: { value: string } }) => + setContacts((prev) => prev.map((c, j) => (j === i ? { ...c, [k]: e.target.value } : c))) + + const save = () => { + if (f.name.trim() === '') { setError('Name is required'); return } + if (!/^\d{2}$/.test(f.stateCode.trim())) { setError('State code must be two digits'); return } + const cleaned: ClientContact[] = contacts + .filter((c) => c.name.trim() !== '' || c.email.trim() !== '' || c.phone.trim() !== '') + .map((c) => ({ + name: c.name.trim() !== '' ? c.name.trim() : (c.email.trim() !== '' ? c.email.trim() : c.phone.trim()), + ...(c.email.trim() !== '' ? { email: c.email.trim() } : {}), + ...(c.phone.trim() !== '' ? { phone: c.phone.trim() } : {}), + ...(c.role.trim() !== '' ? { role: c.role.trim() } : {}), + })) + const gstin = f.gstin.trim() + const body = { + name: f.name.trim(), stateCode: f.stateCode.trim(), + address: f.address, notes: f.notes, contacts: cleaned, + // Server quirk: PATCH accepts gstin: null to clear (repos-clients skips the checksum + // assert on null), but POST's ClientInput.gstin is a bare optional string — sending + // null there crashes assertGstin's .toUpperCase() on null. So: editing may clear via + // null; creating omits the key entirely when blank. + ...(editing + ? { gstin: gstin === '' ? null : gstin.toUpperCase() } + : (gstin !== '' ? { gstin: gstin.toUpperCase() } : {})), + } + setError(undefined) + setSaving(true) + const call = editing ? patchClient(props.initial!.id, body) : createClient(body) + call + .then((c) => { toast.ok(editing ? 'Client saved' : `Client ${c.code} created`); props.onSaved(c); props.onClose() }) + .catch((e: Error) => setError(e.message)) + .finally(() => setSaving(false)) + } + + return ( + + + + + } + > + + + + +