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
Thomas Joise 5 days ago
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>
)
}

@ -20,6 +20,7 @@ import {
AccountOwnerSelect, AssignModuleForm, ClientModuleStatusSelect, AccountOwnerSelect, AssignModuleForm, ClientModuleStatusSelect,
LogInteractionForm, NewAmcForm, NewRecurringForm, PaymentForm, RowDate, LogInteractionForm, NewAmcForm, NewRecurringForm, PaymentForm, RowDate,
} from './client-forms' } from './client-forms'
import { ClientFormDialog } from '../components/ClientFormDialog'
import { PulseRibbon } from '../components/PulseRibbon' import { PulseRibbon } from '../components/PulseRibbon'
import { type PulseEvent } from '../pulse' import { type PulseEvent } from '../pulse'
@ -49,6 +50,7 @@ export function ClientDetail() {
const isOwner = role() === 'owner' const isOwner = role() === 'owner'
const canRoute = isManagerial() const canRoute = isManagerial()
const [actionErr, setActionErr] = useState<string | undefined>() const [actionErr, setActionErr] = useState<string | undefined>()
const [editing, setEditing] = useState(false)
const rawTab = sp.get('tab') ?? 'overview' const rawTab = sp.get('tab') ?? 'overview'
const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview' const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview'
@ -119,6 +121,7 @@ export function ClientDetail() {
} }
actions={ actions={
<> <>
<Button onClick={() => setEditing(true)}>Edit</Button>
<select <select
className="wf" style={{ width: 'auto' }} value={c.status} className="wf" style={{ width: 'auto' }} value={c.status}
onChange={(e) => { onChange={(e) => {
@ -149,6 +152,12 @@ export function ClientDetail() {
</Toolbar> </Toolbar>
)} )}
{actionErr !== undefined && <ErrorState message={actionErr} />} {actionErr !== undefined && <ErrorState message={actionErr} />}
<ClientFormDialog
open={editing}
onClose={() => setEditing(false)}
initial={c}
onSaved={() => client.reload()}
/>
<Tabs <Tabs
active={tab} active={tab}

@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Badge, Button, DataTable, EmptyState, ErrorState, Field, FilterChips, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui' import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, PageHeader, Skeleton, Toolbar } from '@sims/ui'
import { createClient, getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus } from '../api' import { getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus } from '../api'
import { ClientFormDialog } from '../components/ClientFormDialog'
/** Tiny fetch-render hook shared by the HQ pages (same shape as backoffice live.tsx). */ /** Tiny fetch-render hook shared by the HQ pages (same shape as backoffice live.tsx). */
export function useData<T>(loader: () => Promise<T>, deps: unknown[] = []): { export function useData<T>(loader: () => Promise<T>, deps: unknown[] = []): {
@ -75,9 +76,11 @@ export function Clients() {
<span className="spacer" /> <span className="spacer" />
<Badge>{shown.length} shown</Badge> <Badge>{shown.length} shown</Badge>
</Toolbar> </Toolbar>
{creating && ( <ClientFormDialog
<NewClientForm onCreated={(id) => { setCreating(false); reload(); nav(`/clients/${id}`) }} /> open={creating}
)} onClose={() => setCreating(false)}
onSaved={(c) => { reload(); nav(`/clients/${c.id}`) }}
/>
{error !== undefined && <ErrorState message={error} onRetry={reload} />} {error !== undefined && <ErrorState message={error} onRetry={reload} />}
{data === undefined && error === undefined ? <Skeleton rows={6} /> {data === undefined && error === undefined ? <Skeleton rows={6} />
: shown.length === 0 ? <EmptyState>No clients{why !== '' ? why : ' yet — add the first one'}.</EmptyState> : ( : shown.length === 0 ? <EmptyState>No clients{why !== '' ? why : ' yet — add the first one'}.</EmptyState> : (
@ -99,45 +102,3 @@ export function Clients() {
</div> </div>
) )
} }
/** Inline create — name + state are enough; GSTIN is checksum-validated server-side. */
function NewClientForm(props: { onCreated: (id: string) => void }) {
const [f, setF] = useState({ name: '', stateCode: '32', gstin: '', email: '', phone: '' })
const [error, setError] = useState<string | undefined>()
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value }))
const save = () => {
if (f.name.trim() === '') { setError('Name is required'); return }
const contact = f.email !== '' || f.phone !== ''
? [{
name: f.name.trim(),
...(f.phone !== '' ? { phone: f.phone } : {}),
...(f.email !== '' ? { email: f.email } : {}),
}]
: undefined
createClient({
name: f.name.trim(), stateCode: f.stateCode.trim(),
...(f.gstin !== '' ? { gstin: f.gstin.trim().toUpperCase() } : {}),
...(contact !== undefined ? { contacts: contact } : {}),
})
.then((c) => props.onCreated(c.id))
.catch((e: Error) => setError(e.message))
}
return (
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<Field label="Name"><input className="wf" value={f.name} autoFocus onChange={set('name')} /></Field>
<Field label="State code"><input className="wf" style={{ width: 80 }} value={f.stateCode} onChange={set('stateCode')} /></Field>
<Field label="GSTIN (optional)"><input className="wf" value={f.gstin} onChange={set('gstin')} /></Field>
<Field label="Contact email"><input className="wf" value={f.email} onChange={set('email')} /></Field>
<Field label="Contact phone"><input className="wf" value={f.phone} onChange={set('phone')} /></Field>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
<div style={{ marginTop: 8 }}>
<Button tone="primary" onClick={save}>Save client</Button>
</div>
</div>
)
}

Loading…
Cancel
Save