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.
130 lines
6.9 KiB
TypeScript
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>
|
|
)
|
|
}
|