diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 64c8c8b..b7428fb 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -491,11 +491,27 @@ export const putTemplateSettings = ( export const uploadTemplateLogo = (dataUri: string): Promise => 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 => apiFetch('/settings/reminders') +export const putReminderSettings = (body: { overdueDays?: number; renewalDays?: number }): Promise => + apiFetch('/settings/reminders', { method: 'PUT', body: JSON.stringify(body) }) +export const createSchedule = (body: { ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string }): Promise => + 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 => + 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 { diff --git a/apps/hq-web/src/pages/Documents.tsx b/apps/hq-web/src/pages/Documents.tsx index 2ee4678..d511041 100644 --- a/apps/hq-web/src/pages/Documents.tsx +++ b/apps/hq-web/src/pages/Documents.tsx @@ -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: {d.status}, amount: formatINR(d.payablePaise), + actions: d.docType === 'PROFORMA' && d.docNo !== null + && d.status !== 'cancelled' && d.status !== 'invoiced' + ? ( + + ) + : null, }))} /> + {converting !== undefined && ( + 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" + /> + )} Showing {from}–{to} of {data!.total} diff --git a/apps/hq-web/src/pages/Modules.tsx b/apps/hq-web/src/pages/Modules.tsx index dd2b1a8..3de21cb 100644 --- a/apps/hq-web/src/pages/Modules.tsx +++ b/apps/hq-web/src/pages/Modules.tsx @@ -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() const [err, setErr] = useState() + // 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([]) + const [assignPick, setAssignPick] = useState() + const [assignKind, setAssignKind] = useState(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 }) {

Clients on this module

+ {managerial && ( + {assigning && ( +
+ + searchAssign(e.target.value)} /> + + {assignPick === undefined && assignHits.length > 0 && ( +
+ {assignHits.map((c) => ( + + ))} +
+ )} + + + + + setAssignEdition(e.target.value)} /> + + +
+ )} {notifying && managerial && (
setSubject(e.target.value)} /> @@ -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: {c.status}, - 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: {c.status}, + kindEdition: `${KIND_LABEL[c.kind]} · ${c.edition}`, + pays: c.pricePaise !== null ? formatINR(c.pricePaise) : '—', + renewal: c.nextRenewal ?? '—', + actions: ( + + + + + ), + } + } + return { + client: c.clientName, code: c.clientCode, + status: ( + + ), + kindEdition: ( + + + setEdit((p) => p && ({ ...p, edition: e.target.value }))} /> + + ), + pays: c.pricePaise !== null ? formatINR(c.pricePaise) : '—', + renewal: setEdit((p) => p && ({ ...p, renewal: e.target.value }))} />, + actions: ( + + + + + ), + } + })} /> + {unassign !== undefined && ( + 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" + /> + )}
{data.total} client(s) · total {formatINR(data.totalPricePaise)} at current price book {pages > 1 && ( diff --git a/apps/hq/src/repos-modules.ts b/apps/hq/src/repos-modules.ts index f2c8124..adcebbf 100644 --- a/apps/hq/src/repos-modules.ts +++ b/apps/hq/src/repos-modules.ts @@ -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) } diff --git a/apps/hq/test/module-roster.test.ts b/apps/hq/test/module-roster.test.ts index f630e4c..341bc01 100644 --- a/apps/hq/test/module-roster.test.ts +++ b/apps/hq/test/module-roster.test.ts @@ -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 ----------