feat(client-detail): drop mandatory assign creds, add per-module status control, trim summary table

- AssignModuleForm: remove SMS-forced username/password gate at assign time
  (creds now captured later via module Access -> Edit); reword to "Add a
  module" with a one-line hint on what assigning starts.
- Modules summary table: drop Installed/Completed/Trained columns (dates
  live in the per-module status line); keep Module/Kind/Status/Billed/Settled.
- Each module's collapsible block now has an inline ClientModuleStatusSelect
  next to its status badge, so status is editable without opening the table.
- Payments tab: remove the Outstanding panel's duplicate "Advance on
  account" footer line -- the pre-existing StatCard is the single source.
- Remove RowDate, now unused after the summary-table trim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Thomas Joise 2 days ago
parent 78ed947422
commit 025b334b09

@ -29,7 +29,7 @@ import {
} from './Tickets' } from './Tickets'
import { import {
AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect, AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect,
InteractionDialog, PaymentForm, PlanDialog, RowDate, InteractionDialog, PaymentForm, PlanDialog,
} from './client-forms' } from './client-forms'
import { ClientFormDialog } from '../components/ClientFormDialog' import { ClientFormDialog } from '../components/ClientFormDialog'
import { PulseRibbon } from '../components/PulseRibbon' import { PulseRibbon } from '../components/PulseRibbon'
@ -268,11 +268,8 @@ export function ClientDetail() {
{modules.data !== undefined && ( {modules.data !== undefined && (
<AssignModuleForm <AssignModuleForm
modules={modules.data} modules={modules.data}
onAssign={(moduleId, kind, creds) => { onAssign={(moduleId, kind) => {
assignClientModule(id, { moduleId, kind }) assignClientModule(id, { moduleId, kind })
.then((cm) => creds !== undefined
? patchClientModule(cm.id, { username: creds.username, password: creds.password })
: cm)
.then(() => { toast.ok('Module assigned'); cms.reload(); ledger.reload() }) .then(() => { toast.ok('Module assigned'); cms.reload(); ledger.reload() })
.catch((err: Error) => toast.err(err.message)) .catch((err: Error) => toast.err(err.message))
}} }}
@ -285,8 +282,6 @@ export function ClientDetail() {
columns={[ columns={[
{ key: 'module', label: 'Module' }, { key: 'kind', label: 'Kind' }, { key: 'module', label: 'Module' }, { key: 'kind', label: 'Kind' },
{ key: 'status', label: 'Status' }, { key: 'status', label: 'Status' },
{ key: 'installed', label: 'Installed' }, { key: 'completed', label: 'Completed' },
{ key: 'trained', label: 'Trained' },
{ key: 'billed', label: 'Billed', numeric: true }, { key: 'billed', label: 'Billed', numeric: true },
{ key: 'settled', label: 'Settled', numeric: true }, { key: 'settled', label: 'Settled', numeric: true },
]} ]}
@ -296,9 +291,6 @@ export function ClientDetail() {
module: moduleName(cm.moduleId), module: moduleName(cm.moduleId),
kind: KIND_LABEL[cm.kind], kind: KIND_LABEL[cm.kind],
status: <ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} />, status: <ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} />,
installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={toast.err} />,
completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={toast.err} />,
trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={toast.err} />,
billed: paid !== undefined ? inr(paid.billedPaise) : '—', billed: paid !== undefined ? inr(paid.billedPaise) : '—',
settled: paid !== undefined ? inr(paid.settledPaise) : '—', settled: paid !== undefined ? inr(paid.settledPaise) : '—',
} }
@ -314,6 +306,9 @@ export function ClientDetail() {
<span className="mod-name">{moduleName(cm.moduleId)}</span> <span className="mod-name">{moduleName(cm.moduleId)}</span>
<Badge tone={cm.status === 'live' ? 'ok' : undefined}>{cm.status}</Badge> <Badge tone={cm.status === 'live' ? 'ok' : undefined}>{cm.status}</Badge>
<Badge>{KIND_LABEL[cm.kind]}</Badge> <Badge>{KIND_LABEL[cm.kind]}</Badge>
<span onClick={(e) => e.stopPropagation()}>
<ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} />
</span>
</summary> </summary>
<div className="mod-section-body"> <div className="mod-section-body">
<ServiceDataCard <ServiceDataCard
@ -439,8 +434,6 @@ export function ClientDetail() {
<span className="due">{inr(o.outstandingPaise)}</span> <span className="due">{inr(o.outstandingPaise)}</span>
</div> </div>
))} ))}
<div className="ofoot"><span style={{ color: 'var(--text-dim)', fontSize: 12 }}>Advance on account</span>
<span className="mono" style={{ marginLeft: 'auto', fontWeight: 600 }}>{formatINR(ledger.data.advancePaise)}</span></div>
</div> </div>
</div> </div>
)} )}

@ -284,25 +284,21 @@ export function InteractionDialog(props: {
export function AssignModuleForm(props: { export function AssignModuleForm(props: {
modules: Module[] modules: Module[]
onAssign: (moduleId: string, kind: Kind, creds?: { username: string; password: string }) => void onAssign: (moduleId: string, kind: Kind) => void
}) { }) {
const [moduleId, setModuleId] = useState('') const [moduleId, setModuleId] = useState('')
const mod = props.modules.find((m) => m.id === moduleId) const mod = props.modules.find((m) => m.id === moduleId)
const [kind, setKind] = useState<Kind | ''>('') const [kind, setKind] = useState<Kind | ''>('')
const [username, setUsername] = useState('') const ready = moduleId !== '' && kind !== ''
const [password, setPassword] = useState('') const reset = () => { setModuleId(''); setKind('') }
// SMS is a gateway service — its login is required at assign time so the balance pull works.
const needsCreds = mod?.code === 'SMS'
const ready = moduleId !== '' && kind !== '' && (!needsCreds || (username.trim() !== '' && password.trim() !== ''))
const reset = () => { setModuleId(''); setKind(''); setUsername(''); setPassword('') }
return ( return (
<div className="wf-card" style={{ padding: '10px 14px', marginBottom: 10 }}> <div className="wf-card" style={{ padding: '10px 14px', marginBottom: 10 }}>
<Toolbar> <Toolbar>
<select <select
className="wf" value={moduleId} className="wf" value={moduleId}
onChange={(e) => { setModuleId(e.target.value); setKind(''); setUsername(''); setPassword('') }} onChange={(e) => { setModuleId(e.target.value); setKind('') }}
> >
<option value="">Assign module</option> <option value="">Add a module</option>
{props.modules.filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)} {props.modules.filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select> </select>
{mod !== undefined && ( {mod !== undefined && (
@ -311,30 +307,20 @@ export function AssignModuleForm(props: {
{mod.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)} {mod.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select> </select>
)} )}
{needsCreds && (
<>
<input className="wf mono" style={{ width: 150 }} placeholder="SMS username *" aria-label="SMS username"
value={username} onChange={(e) => setUsername(e.target.value)} />
<input className="wf mono" style={{ width: 150 }} placeholder="SMS password *" aria-label="SMS password"
value={password} onChange={(e) => setPassword(e.target.value)} />
</>
)}
<Button <Button
tone="primary" disabled={!ready} tone="primary" disabled={!ready}
onClick={() => { onClick={() => {
if (!ready) return if (!ready) return
props.onAssign(moduleId, kind as Kind, needsCreds ? { username: username.trim(), password: password.trim() } : undefined) props.onAssign(moduleId, kind as Kind)
reset() reset()
}} }}
> >
Assign Add a module
</Button> </Button>
</Toolbar> </Toolbar>
{needsCreds && ( <span style={{ fontSize: 12, color: 'var(--text-dim)' }}>
<span style={{ fontSize: 12, color: 'var(--text-dim)' }}> Starts the module at "quoted" and opens its onboarding status line.
SMS needs the provider gateway login username and password are required so the balance can be pulled. </span>
</span>
)}
</div> </div>
) )
} }
@ -355,22 +341,6 @@ export function ClientModuleStatusSelect(props: {
) )
} }
export function RowDate(props: {
cm: ClientModule; field: 'installedOn' | 'completedOn' | 'trainedOn'
onPatched: () => void; onError: (msg: string) => void
}) {
return (
<input
type="date" className="wf" style={{ width: 140 }}
value={props.cm[props.field] ?? ''}
onChange={(e) => {
patchClientModule(props.cm.id, { [props.field]: e.target.value !== '' ? e.target.value : null })
.then(props.onPatched).catch((err: Error) => props.onError(err.message))
}}
/>
)
}
/** /**
* Record a payment against the client amounts entered in rupees, stored in * Record a payment against the client amounts entered in rupees, stored in
* integer paise via fromRupees. Shared with DocumentView's "Record payment". * integer paise via fromRupees. Shared with DocumentView's "Record payment".

Loading…
Cancel
Save