feat(hq-web): client create/edit dialog with contacts repeater — clients finally editable
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/client-detail-redesign
parent
e612c5cf81
commit
0aca8b42b4
@ -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<ContactDraft[]>([])
|
||||||
|
const [error, setError] = useState<string | undefined>()
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
open={props.open}
|
||||||
|
onClose={props.onClose}
|
||||||
|
title={editing ? `Edit ${props.initial!.name}` : 'New client'}
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button pill onClick={props.onClose}>Cancel</Button>
|
||||||
|
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : editing ? 'Save changes' : 'Create client'}</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormGrid>
|
||||||
|
<FormField label="Name" wide><input className="wf" autoFocus value={f.name} onChange={set('name')} /></FormField>
|
||||||
|
<FormField label="GSTIN (optional)"><input className="wf mono" value={f.gstin} onChange={set('gstin')} /></FormField>
|
||||||
|
<FormField label="State code"><input className="wf" style={{ width: 80 }} value={f.stateCode} onChange={set('stateCode')} /></FormField>
|
||||||
|
<FormField label="Address" wide><textarea className="wf" rows={2} value={f.address} onChange={set('address')} /></FormField>
|
||||||
|
<FormField label="Notes" wide><textarea className="wf" rows={2} value={f.notes} onChange={set('notes')} /></FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<h3 style={{ margin: '14px 0 6px', fontSize: 13 }}>Contacts</h3>
|
||||||
|
{contacts.map((c, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<input className="wf" placeholder="Name" value={c.name} onChange={setContact(i, 'name')} />
|
||||||
|
<input className="wf" placeholder="Email" type="email" value={c.email} onChange={setContact(i, 'email')} />
|
||||||
|
<input className="wf" placeholder="Phone" value={c.phone} onChange={setContact(i, 'phone')} />
|
||||||
|
<input className="wf" placeholder="Role" style={{ maxWidth: 110 }} value={c.role} onChange={setContact(i, 'role')} />
|
||||||
|
<Button onClick={() => setContacts((prev) => prev.filter((_x, j) => j !== i))}>✕</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button onClick={() => setContacts((prev) => [...prev, { ...EMPTY_CONTACT }])}>+ Add contact</Button>
|
||||||
|
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue