You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/components/ClientFormDialog.tsx

130 lines
6.9 KiB
TypeScript

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: '',
anydesk: '', os: '', district: '', sector: '',
})
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 ?? '',
anydesk: c?.anydesk ?? '', os: c?.os ?? '', district: c?.district ?? '', sector: c?.sector ?? '',
})
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 (saving) return
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,
// Support-access fields (D18 WS-F). PATCH clears on ''; POST omits blanks.
...(editing
? { anydesk: f.anydesk, os: f.os, district: f.district, sector: f.sector }
: {
...(f.anydesk.trim() !== '' ? { anydesk: f.anydesk.trim() } : {}),
...(f.os.trim() !== '' ? { os: f.os.trim() } : {}),
...(f.district.trim() !== '' ? { district: f.district.trim() } : {}),
...(f.sector.trim() !== '' ? { sector: f.sector.trim() } : {}),
}),
// 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={() => { if (!saving) props.onClose() }}
title={editing ? `Edit ${props.initial!.name}` : 'New client'}
size="lg"
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" disabled={saving} 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="District"><input className="wf" value={f.district} onChange={set('district')} /></FormField>
<FormField label="Sector"><input className="wf" value={f.sector} onChange={set('sector')} /></FormField>
<FormField label="AnyDesk ID"><input className="wf mono" value={f.anydesk} onChange={set('anydesk')} /></FormField>
<FormField label="OS"><input className="wf" value={f.os} onChange={set('os')} /></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" aria-label="Contact name" value={c.name} onChange={setContact(i, 'name')} />
<input className="wf" placeholder="Email" aria-label="Contact email" type="email" value={c.email} onChange={setContact(i, 'email')} />
<input className="wf" placeholder="Phone" aria-label="Contact phone" value={c.phone} onChange={setContact(i, 'phone')} />
{/* Typed roles (D18 WS-F): whatsapp/secretary get labeled rows on the support card; free text stays allowed. */}
<input className="wf" placeholder="Role" aria-label="Contact role" list="hq-contact-roles" style={{ maxWidth: 110 }} value={c.role} onChange={setContact(i, 'role')} />
<Button aria-label="Remove contact" onClick={() => setContacts((prev) => prev.filter((_x, j) => j !== i))}></Button>
</div>
))}
<datalist id="hq-contact-roles">
<option value="whatsapp" />
<option value="secretary" />
</datalist>
<Button onClick={() => setContacts((prev) => [...prev, { ...EMPTY_CONTACT }])}>+ Add contact</Button>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</Dialog>
)
}