feat(modules): roster full-edit + assign/unassign; one-click Convert & send on the documents register (Phase 8, D18 WS-G)

- listClientsByModule rows carry cmId; ClientModulePatch gains kind/edition
  (kind validated against module.allowedKinds — same audited write path as
  Client 360, so the two views can never disagree)
- Modules roster: inline edit (kind/edition/renewal/status), ✕ unassign with
  confirm (deactivates the link, totals shrink), Assign client… type-ahead
- Documents register: issued proformas get a Convert & send quick action
  (same convert-and-send endpoint + confirm as the document view)
- 2 tests; suite 333 green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 3f7f08a908
commit 747026fa3b

@ -491,11 +491,27 @@ export const putTemplateSettings = (
export const uploadTemplateLogo = (dataUri: string): Promise<string> =>
apiFetch<{ logo: string }>('/settings/template/logo', { method: 'POST', body: JSON.stringify({ dataUri }) }).then((r) => r.logo)
export interface ScheduleRow {
id: string; ruleKind: string; effectiveFrom: string; effectiveTo: string | null
dayOffsets: string; subject: string | null; body: string | null
}
export interface ReminderSettings { overdueDays: number; renewalDays: number; schedules: ScheduleRow[]; total: number }
export const getReminderSettings = (): Promise<ReminderSettings> => apiFetch('/settings/reminders')
export const putReminderSettings = (body: { overdueDays?: number; renewalDays?: number }): Promise<void> =>
apiFetch('/settings/reminders', { method: 'PUT', body: JSON.stringify(body) })
export const createSchedule = (body: { ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string }): Promise<ScheduleRow> =>
apiFetch<{ schedule: ScheduleRow }>('/settings/schedules', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.schedule)
export const getSharingSettings = (): Promise<{ defaultExpiryDays: number | null }> => apiFetch('/settings/sharing')
export const putSharingSettings = (defaultExpiryDays: number | null): Promise<void> =>
apiFetch('/settings/sharing', { method: 'PUT', body: JSON.stringify({ defaultExpiryDays }) })
// ---------- module → client roster (spec §10) ----------
export interface ModuleClientRow {
/** client_module id — handle for the roster's inline edit/unassign (WS-G). */
cmId: string
clientId: string; clientName: string; clientCode: string
status: string; kind: Kind; edition: string
status: ClientModuleStatus; kind: Kind; edition: string
nextRenewal: string | null; pricePaise: number | null
}
export interface ModuleClientsPage {

@ -1,8 +1,11 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import { Badge, DataTable, EmptyState, ErrorState, FilterChips, PageHeader, Skeleton, Toolbar } from '@sims/ui'
import { getDocuments, DOC_STATUSES, type DocStatus, type DocType } from '../api'
import {
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, FilterChips, PageHeader,
Skeleton, Toolbar, useToast,
} from '@sims/ui'
import { documentAction, getDocuments, DOC_STATUSES, type DocStatus, type DocType } from '../api'
import { useData, DOC_TONE } from './Clients'
const PAGE_SIZE = 50
@ -27,9 +30,12 @@ const TYPE_CHIPS: { key: string; label: string }[] = [
*/
export function Documents() {
const nav = useNavigate()
const toast = useToast()
const [type, setType] = useState('all')
const [status, setStatus] = useState('')
const [page, setPage] = useState(1)
// D18 WS-G: one-click proforma → invoice & send, straight from the register.
const [converting, setConverting] = useState<{ id: string; docNo: string } | undefined>()
const list = useData(
() => getDocuments({
@ -85,6 +91,7 @@ export function Documents() {
{ key: 'date', label: 'Date' },
{ key: 'status', label: 'Status' },
{ key: 'amount', label: 'Amount', numeric: true },
{ key: 'actions', label: '' },
]}
onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
rows={docs.map((d) => ({
@ -94,8 +101,31 @@ export function Documents() {
date: d.docDate,
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
amount: formatINR(d.payablePaise),
actions: d.docType === 'PROFORMA' && d.docNo !== null
&& d.status !== 'cancelled' && d.status !== 'invoiced'
? (
<Button
tone="primary"
onClick={() => setConverting({ id: d.id, docNo: d.docNo! })}
>
Convert & send
</Button>
)
: null,
}))}
/>
{converting !== undefined && (
<ConfirmDialog
open
onClose={() => setConverting(undefined)}
onConfirm={() => documentAction(converting.id, 'convert-and-send')
.then((doc) => { toast.ok('Invoice issued & emailed'); nav(`/documents/${doc.id}`) })
.catch((e: Error) => { toast.err(e.message); setConverting(undefined) })}
title="Convert to invoice & send"
body={`Convert ${converting.docNo} to a tax invoice, assign its number and email it to the client?`}
confirmLabel="Convert & send"
/>
)}
<Toolbar>
<Badge>Showing {from}{to} of {data!.total}</Badge>
<span style={{ flex: 1 }} />

@ -1,13 +1,15 @@
import { useEffect, useMemo, useState } from 'react'
import { formatINR, fromRupees } from '@sims/domain'
import {
Badge, Button, DataTable, Dialog, EmptyState, ErrorState, Field, FormField, FormGrid, Notice,
PageHeader, Skeleton, Toolbar, useToast,
Badge, Button, ConfirmDialog, DataTable, Dialog, EmptyState, ErrorState, Field, FormField,
FormGrid, Notice, PageHeader, Skeleton, Toolbar, useToast,
} from '@sims/ui'
import {
addPrice, createModule, downloadModuleClientsCsv, getModuleClients, getModules, getPrices,
notifyModuleClients, patchModule, previewSample, role,
KIND_LABEL, type Kind, type Module,
addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients,
getModuleClients, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule,
previewSample, role,
CLIENT_MODULE_STATUSES, KIND_LABEL,
type Client, type ClientModuleStatus, type Kind, type Module,
} from '../api'
import { LivePreview } from '../components/LivePreview'
import { useData } from './Clients'
@ -85,6 +87,44 @@ function ModuleClients(props: { module: Module }) {
const [result, setResult] = useState<string | undefined>()
const [err, setErr] = useState<string | undefined>()
// D18 WS-G full-edit: one row in edit mode at a time; writes go through the SAME
// audited repo calls Client 360 uses (patchClientModule / assignClientModule).
const [edit, setEdit] = useState<{
cmId: string; kind: Kind; edition: string; renewal: string; status: ClientModuleStatus
} | undefined>()
const [unassign, setUnassign] = useState<{ cmId: string; clientName: string } | undefined>()
const [assigning, setAssigning] = useState(false)
const [assignQ, setAssignQ] = useState('')
const [assignHits, setAssignHits] = useState<Client[]>([])
const [assignPick, setAssignPick] = useState<Client | undefined>()
const [assignKind, setAssignKind] = useState<Kind>(props.module.allowedKinds[0] ?? 'yearly')
const [assignEdition, setAssignEdition] = useState('standard')
const saveEdit = () => {
if (edit === undefined || busy) return
setBusy(true); setErr(undefined)
patchClientModule(edit.cmId, {
kind: edit.kind, edition: edit.edition,
nextRenewal: edit.renewal === '' ? null : edit.renewal, status: edit.status,
})
.then(() => { setEdit(undefined); roster.reload() })
.catch((e: Error) => setErr(e.message))
.finally(() => setBusy(false))
}
const searchAssign = (q: string) => {
setAssignQ(q); setAssignPick(undefined)
if (q.trim().length < 2) { setAssignHits([]); return }
getClients(q).then((cs) => setAssignHits(cs.slice(0, 6))).catch(() => setAssignHits([]))
}
const doAssign = () => {
if (assignPick === undefined || busy) return
setBusy(true); setErr(undefined)
assignClientModule(assignPick.id, { moduleId: props.module.id, kind: assignKind, edition: assignEdition })
.then(() => { setAssigning(false); setAssignQ(''); setAssignPick(undefined); roster.reload() })
.catch((e: Error) => setErr(e.message))
.finally(() => setBusy(false))
}
const doNotify = () => {
setBusy(true); setErr(undefined); setResult(undefined)
notifyModuleClients(props.module.id, { subject, body: bodyText })
@ -103,6 +143,9 @@ function ModuleClients(props: { module: Module }) {
<section>
<h2>Clients on this module</h2>
<Toolbar>
<Button onClick={() => setAssigning((v) => !v)}>
{assigning ? 'Close assign' : 'Assign client…'}
</Button>
{managerial && (
<Button onClick={() => setNotifying((v) => !v)}>
{notifying ? 'Close composer' : `Notify all${data !== undefined ? ` (${data.total})` : ''}`}
@ -112,6 +155,32 @@ function ModuleClients(props: { module: Module }) {
Export CSV
</Button>
</Toolbar>
{assigning && (
<div className="wf-form">
<Field label="Client (search by name/code)">
<input value={assignPick !== undefined ? `${assignPick.code} · ${assignPick.name}` : assignQ}
onChange={(e) => searchAssign(e.target.value)} />
</Field>
{assignPick === undefined && assignHits.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 8 }}>
{assignHits.map((c) => (
<Button key={c.id} onClick={() => setAssignPick(c)}>{c.code} · {c.name}</Button>
))}
</div>
)}
<Field label="Kind">
<select className="wf" value={assignKind} onChange={(e) => setAssignKind(e.target.value as Kind)}>
{props.module.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select>
</Field>
<Field label="Edition / pack">
<input value={assignEdition} onChange={(e) => setAssignEdition(e.target.value)} />
</Field>
<Button tone="primary" onClick={() => { if (assignPick !== undefined && !busy) doAssign() }}>
{busy ? 'Assigning…' : 'Assign to module'}
</Button>
</div>
)}
{notifying && managerial && (
<div className="wf-form">
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} /></Field>
@ -137,15 +206,64 @@ function ModuleClients(props: { module: Module }) {
{ key: 'client', label: 'Client' }, { key: 'code', label: 'Code', mono: true },
{ key: 'status', label: 'Status' }, { key: 'kindEdition', label: 'Kind · Edition' },
{ key: 'pays', label: 'Pays' }, { key: 'renewal', label: 'Next renewal' },
{ key: 'actions', label: '' },
]}
rows={data.clients.map((c) => ({
client: c.clientName, code: c.clientCode,
status: <Badge tone={c.status === 'live' ? 'ok' : undefined}>{c.status}</Badge>,
kindEdition: `${KIND_LABEL[c.kind]} · ${c.edition}`,
pays: c.pricePaise !== null ? formatINR(c.pricePaise) : '—',
renewal: c.nextRenewal ?? '—',
}))}
rows={data.clients.map((c) => {
const editing = edit !== undefined && edit.cmId === c.cmId
if (!editing) {
return {
client: c.clientName, code: c.clientCode,
status: <Badge tone={c.status === 'live' ? 'ok' : undefined}>{c.status}</Badge>,
kindEdition: `${KIND_LABEL[c.kind]} · ${c.edition}`,
pays: c.pricePaise !== null ? formatINR(c.pricePaise) : '—',
renewal: c.nextRenewal ?? '—',
actions: (
<span style={{ display: 'flex', gap: 6 }}>
<Button onClick={() => setEdit({ cmId: c.cmId, kind: c.kind, edition: c.edition, renewal: c.nextRenewal ?? '', status: c.status })}>Edit</Button>
<Button tone="danger" onClick={() => setUnassign({ cmId: c.cmId, clientName: c.clientName })}></Button>
</span>
),
}
}
return {
client: c.clientName, code: c.clientCode,
status: (
<select className="wf" value={edit.status} onChange={(e) => setEdit((p) => p && ({ ...p, status: e.target.value as ClientModuleStatus }))}>
{CLIENT_MODULE_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
),
kindEdition: (
<span style={{ display: 'flex', gap: 6 }}>
<select className="wf" value={edit.kind} onChange={(e) => setEdit((p) => p && ({ ...p, kind: e.target.value as Kind }))}>
{props.module.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select>
<input className="wf" style={{ width: 90 }} value={edit.edition} onChange={(e) => setEdit((p) => p && ({ ...p, edition: e.target.value }))} />
</span>
),
pays: c.pricePaise !== null ? formatINR(c.pricePaise) : '—',
renewal: <input className="wf" type="date" value={edit.renewal} onChange={(e) => setEdit((p) => p && ({ ...p, renewal: e.target.value }))} />,
actions: (
<span style={{ display: 'flex', gap: 6 }}>
<Button tone="primary" onClick={saveEdit}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => setEdit(undefined)}>Cancel</Button>
</span>
),
}
})}
/>
{unassign !== undefined && (
<ConfirmDialog
open
onClose={() => setUnassign(undefined)}
onConfirm={() => patchClientModule(unassign.cmId, { active: false })
.then(() => { setUnassign(undefined); roster.reload() })
.catch((e: Error) => setErr(e.message))}
title="Unassign client"
body={`Remove ${unassign.clientName} from ${props.module.code}? The link is deactivated (history kept), and the roster total shrinks.`}
confirmLabel="Unassign"
tone="danger"
/>
)}
<div className="wf-pager">
<span>{data.total} client(s) · total {formatINR(data.totalPricePaise)} at current price book</span>
{pages > 1 && (

@ -198,6 +198,8 @@ export function listClientModules(db: DB, clientId: string): ClientModule[] {
// ---------- module → client roster (spec §10, D-ROSTER) ----------
export interface ModuleClientRow {
/** client_module id — the handle the roster's inline edit/unassign writes through. */
cmId: string
clientId: string; clientName: string; clientCode: string
status: ClientModuleStatus; kind: Kind; edition: string
nextRenewal: string | null
@ -229,17 +231,18 @@ export function listClientsByModule(
`SELECT COUNT(*) AS n FROM client_module WHERE module_id=? AND active=1`,
).get(moduleId) as { n: number }).n
const rows = db.prepare(
`SELECT cm.client_id, c.name AS client_name, c.code AS client_code,
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
cm.status, cm.kind, cm.edition, cm.next_renewal
FROM client_module cm JOIN client c ON c.id = cm.client_id
WHERE cm.module_id=? AND cm.active=1
ORDER BY c.name, cm.id
LIMIT ? OFFSET ?`,
).all(moduleId, pageSize, (page - 1) * pageSize) as {
client_id: string; client_name: string; client_code: string
cm_id: string; client_id: string; client_name: string; client_code: string
status: string; kind: string; edition: string; next_renewal: string | null
}[]
const clients = rows.map((r): ModuleClientRow => ({
cmId: r.cm_id,
clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
status: r.status as ClientModuleStatus, kind: r.kind as Kind, edition: r.edition,
nextRenewal: r.next_renewal,
@ -288,6 +291,8 @@ export function assignModule(db: DB, userId: string, input: AssignModuleInput):
export interface ClientModulePatch {
status?: ClientModuleStatus; installedOn?: string | null; completedOn?: string | null
trainedOn?: string | null; nextRenewal?: string | null; active?: boolean
/** D18 WS-G (roster full-edit): kind must be one the module allows; edition free text. */
kind?: Kind; edition?: string
}
export function updateClientModule(db: DB, userId: string, id: string, patch: ClientModulePatch): ClientModule {
@ -296,8 +301,20 @@ export function updateClientModule(db: DB, userId: string, id: string, patch: Cl
if (patch.status !== undefined && !ALL_STATUSES.includes(patch.status)) {
throw new Error(`Unknown status: ${patch.status}`)
}
if (patch.kind !== undefined) {
const mod = getModule(db, before.moduleId)
if (mod === null) throw new Error('Module not found')
if (!mod.allowedKinds.includes(patch.kind)) {
throw new Error(`Module ${mod.code} does not allow kind '${patch.kind}'`)
}
}
if (patch.edition !== undefined && patch.edition.trim() === '') {
throw new Error('Edition cannot be empty')
}
const sets: string[] = []
const args: unknown[] = []
if (patch.kind !== undefined) { sets.push('kind=?'); args.push(patch.kind) }
if (patch.edition !== undefined) { sets.push('edition=?'); args.push(patch.edition.trim()) }
if (patch.status !== undefined) { sets.push('status=?'); args.push(patch.status) }
if (patch.installedOn !== undefined) { sets.push('installed_on=?'); args.push(patch.installedOn) }
if (patch.completedOn !== undefined) { sets.push('completed_on=?'); args.push(patch.completedOn) }

@ -62,6 +62,30 @@ describe('listClientsByModule', () => {
const page = listClientsByModule(db, m.id)
expect(page).toMatchObject({ total: 0, clients: [], totalPricePaise: 0 })
})
it('rows carry cmId, and roster edits round-trip through updateClientModule (WS-G full-edit)', () => {
const { db, m, mkClient } = world()
const c = mkClient('Acme Bank')
assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const row = listClientsByModule(db, m.id).clients[0]!
expect(row.cmId).toBeTruthy()
// Same audited write path Client 360 uses — kind validated against allowedKinds.
updateClientModule(db, 'u1', row.cmId, { kind: 'monthly', edition: 'gold', nextRenewal: '2026-12-01', status: 'live' })
const after = listClientsByModule(db, m.id).clients[0]!
expect(after).toMatchObject({ kind: 'monthly', edition: 'gold', nextRenewal: '2026-12-01', status: 'live' })
// Unassign = deactivate: the row leaves the roster and the total shrinks.
updateClientModule(db, 'u1', row.cmId, { active: false })
expect(listClientsByModule(db, m.id).total).toBe(0)
})
it('roster kind edit rejects a kind the module does not allow', () => {
const { db, mkClient } = world()
const restricted = createModule(db, 'u1', { code: 'AMC2', name: 'AMC Only', allowedKinds: ['yearly'] })
const c = mkClient('Beta Coop')
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: restricted.id, kind: 'yearly' })
expect(() => updateClientModule(db, 'u1', cm.id, { kind: 'usage' })).toThrow(/does not allow/)
expect(() => updateClientModule(db, 'u1', cm.id, { edition: ' ' })).toThrow(/edition/i)
})
})
// ---------- routes ----------

Loading…
Cancel
Save