feat(hq-web): clients, modules, composer and document pages (HQ-1 task 16)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 10529beb42
commit 8c145d5a3b

@ -61,3 +61,145 @@ export async function login(email: string, password: string): Promise<{ displayN
export interface EmailStatus { connected: boolean; address?: string; dead: boolean }
export const getEmailStatus = (): Promise<EmailStatus> => apiFetch<EmailStatus>('/email/status')
// ---------- shared server shapes (mirror apps/hq repos — camelCase JSON) ----------
export interface ClientContact { name: string; phone?: string; email?: string; role?: string }
export type ClientStatus = 'lead' | 'active' | 'dormant' | 'lost'
export const CLIENT_STATUSES: ClientStatus[] = ['lead', 'active', 'dormant', 'lost']
export interface Client {
id: string; code: string; name: string; gstin?: string; stateCode: string
address: string; contacts: ClientContact[]; status: ClientStatus; notes: string
}
export type Kind = 'one_time' | 'monthly' | 'yearly' | 'usage'
export const KIND_LABEL: Record<Kind, string> = {
one_time: 'One-time', monthly: 'Monthly', yearly: 'Yearly', usage: 'Usage',
}
export interface Module {
id: string; code: string; name: string; sac: string
allowedKinds: Kind[]; multiSubscription: boolean; active: boolean
}
export interface ModulePrice {
id: string; moduleId: string; edition: string; kind: Kind
pricePaise: number; effectiveFrom: string
}
export type ClientModuleStatus =
| 'quoted' | 'ordered' | 'installing' | 'installed'
| 'trained' | 'live' | 'expired' | 'cancelled'
export const CLIENT_MODULE_STATUSES: ClientModuleStatus[] = [
'quoted', 'ordered', 'installing', 'installed', 'trained', 'live', 'expired', 'cancelled',
]
export interface ClientModule {
id: string; clientId: string; moduleId: string; status: ClientModuleStatus
kind: Kind; edition: string
installedOn: string | null; completedOn: string | null; trainedOn: string | null
nextRenewal: string | null; active: boolean
}
export type DocType = 'QUOTATION' | 'PROFORMA' | 'INVOICE' | 'RECEIPT' | 'CREDIT_NOTE'
export type DocStatus =
| 'draft' | 'sent' | 'accepted' | 'invoiced' | 'part_paid' | 'paid' | 'lost' | 'cancelled'
export interface DocLine {
itemId: string; name: string; hsn: string; qty: number; unitCode: string
unitPricePaise: number; taxablePaise: number; lineTotalPaise: number
}
export interface Doc {
id: string; docType: DocType; docNo: string | null; fy: string; clientId: string
docDate: string; status: DocStatus; refDocId: string | null
taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number
roundOffPaise: number; payablePaise: number
payload: { lines: DocLine[]; terms?: string }
source: string; createdBy: string; createdAt: string
}
export interface DocumentEvent {
id: string; documentId: string; atWall: string; kind: string; meta: Record<string, unknown>
}
/** email_log rows come straight off the table (snake_case). */
export interface EmailLogRow {
id: string; document_id: string | null; to_addr: string; subject: string
status: 'sent' | 'failed'; gmail_message_id: string | null; error: string | null; at_wall: string
}
export type PaymentMode = 'bank' | 'upi' | 'cheque' | 'cash' | 'other'
export const PAYMENT_MODES: PaymentMode[] = ['bank', 'upi', 'cheque', 'cash', 'other']
export interface Payment {
id: string; clientId: string; receivedOn: string; mode: PaymentMode
reference: string; amountPaise: number; tdsPaise: number
createdBy: string; createdAt: string
}
export interface Ledger {
documents: Doc[]; payments: Payment[]; advancePaise: number
modulePaid: { moduleId: string; billedPaise: number; settledPaise: number }[]
}
// ---------- typed calls ----------
export const getClients = (q?: string): Promise<Client[]> =>
apiFetch<{ clients: Client[] }>(`/clients${q !== undefined && q !== '' ? `?q=${encodeURIComponent(q)}` : ''}`)
.then((r) => r.clients)
export const getClient = (id: string): Promise<Client> =>
apiFetch<{ client: Client }>(`/clients/${id}`).then((r) => r.client)
export const createClient = (body: Record<string, unknown>): Promise<Client> =>
apiFetch<{ client: Client }>('/clients', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.client)
export const patchClient = (id: string, body: Record<string, unknown>): Promise<Client> =>
apiFetch<{ client: Client }>(`/clients/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.client)
export const getModules = (): Promise<Module[]> =>
apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules)
export const createModule = (body: Record<string, unknown>): Promise<Module> =>
apiFetch<{ module: Module }>('/modules', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.module)
export const getPrices = (moduleId: string): Promise<ModulePrice[]> =>
apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`).then((r) => r.prices)
export const addPrice = (moduleId: string, body: Record<string, unknown>): Promise<ModulePrice[]> =>
apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`, { method: 'POST', body: JSON.stringify(body) })
.then((r) => r.prices)
export const getClientModules = (clientId: string): Promise<ClientModule[]> =>
apiFetch<{ clientModules: ClientModule[] }>(`/clients/${clientId}/modules`).then((r) => r.clientModules)
export const assignClientModule = (clientId: string, body: Record<string, unknown>): Promise<ClientModule> =>
apiFetch<{ clientModule: ClientModule }>(`/clients/${clientId}/modules`, { method: 'POST', body: JSON.stringify(body) })
.then((r) => r.clientModule)
export const patchClientModule = (id: string, body: Record<string, unknown>): Promise<ClientModule> =>
apiFetch<{ clientModule: ClientModule }>(`/client-modules/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
.then((r) => r.clientModule)
export const createDocument = (body: Record<string, unknown>): Promise<Doc> =>
apiFetch<{ document: Doc }>('/documents', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.document)
export const getDocumentFull = (id: string): Promise<{ document: Doc; events: DocumentEvent[]; emails: EmailLogRow[] }> =>
apiFetch(`/documents/${id}`)
/** issue / status / convert / cancel / credit-note / send — all POST → { document }. */
export const documentAction = (id: string, action: string, body?: Record<string, unknown>): Promise<Doc> =>
apiFetch<{ document: Doc }>(`/documents/${id}/${action}`, {
method: 'POST', body: JSON.stringify(body ?? {}),
}).then((r) => r.document)
export const recordPayment = (body: Record<string, unknown>): Promise<Payment> =>
apiFetch<{ payment: Payment }>('/payments', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.payment)
export const getLedger = (clientId: string): Promise<Ledger> =>
apiFetch<Ledger>(`/clients/${clientId}/ledger`)
/** The PDF route needs the bearer header, which an <iframe src> can't carry — fetch a blob instead. */
export async function fetchPdfBlob(documentId: string): Promise<Blob> {
const token = localStorage.getItem(TOKEN_KEY) ?? ''
const res = await fetch(`/api/documents/${documentId}/pdf`, {
headers: token !== '' ? { authorization: `Bearer ${token}` } : {},
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(body.error ?? `PDF failed: HTTP ${res.status}`)
}
return res.blob()
}

@ -4,14 +4,14 @@ import '@fontsource-variable/inter'
import '@fontsource-variable/jetbrains-mono'
import '../../../packages/ui/src/tokens.css'
import './app.css'
import { initTheme, PageHeader } from '@sims/ui'
import { initTheme } from '@sims/ui'
import { Layout } from './Layout'
import { Login } from './Login'
/** Placeholder pages — Task 16 replaces these with the five real pages. */
function Placeholder(props: { title: string }) {
return <PageHeader title={props.title} desc="Coming in the next build step." />
}
import { Clients } from './pages/Clients'
import { ClientDetail } from './pages/ClientDetail'
import { Modules } from './pages/Modules'
import { NewDocument } from './pages/NewDocument'
import { DocumentView } from './pages/DocumentView'
function App() {
return (
@ -19,11 +19,11 @@ function App() {
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route path="/" element={<Placeholder title="Clients" />} />
<Route path="/clients/:id" element={<Placeholder title="Client" />} />
<Route path="/modules" element={<Placeholder title="Modules" />} />
<Route path="/documents/new" element={<Placeholder title="New Document" />} />
<Route path="/documents/:id" element={<Placeholder title="Document" />} />
<Route path="/" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} />
<Route path="/modules" element={<Modules />} />
<Route path="/documents/new" element={<NewDocument />} />
<Route path="/documents/:id" element={<DocumentView />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>

@ -0,0 +1,261 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui'
import {
assignClientModule, getClient, getClientModules, getLedger, getModules,
patchClient, patchClientModule, recordPayment,
CLIENT_MODULE_STATUSES, CLIENT_STATUSES, KIND_LABEL, PAYMENT_MODES,
type ClientModule, type ClientModuleStatus, type ClientStatus, type Kind,
type Module, type PaymentMode,
} from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
const inr = (p: number) => formatINR(p, { symbol: false })
const today = () => new Date().toISOString().slice(0, 10)
/** Client 360° — header, modules, documents, payments & dues (14-SPEC §Client registry). */
export function ClientDetail() {
const id = useParams()['id'] ?? ''
const nav = useNavigate()
const client = useData(() => getClient(id), [id])
const modules = useData(getModules, [])
const cms = useData(() => getClientModules(id), [id])
const ledger = useData(() => getLedger(id), [id])
const [actionErr, setActionErr] = useState<string | undefined>()
const c = client.data
if (client.error !== undefined) return <Notice tone="err">{client.error}</Notice>
if (c === undefined) return <EmptyState>Loading</EmptyState>
const moduleName = (moduleId: string) =>
modules.data?.find((m) => m.id === moduleId)?.name ?? moduleId
return (
<div className="wf-page">
<PageHeader
title={c.name} badge={c.code}
desc={`State ${c.stateCode}${c.gstin !== undefined && c.gstin !== '' ? ` · GSTIN ${c.gstin}` : ''}${c.address !== '' ? ` · ${c.address}` : ''}`}
actions={
<select
className="wf" value={c.status}
onChange={(e) => {
setActionErr(undefined)
patchClient(id, { status: e.target.value as ClientStatus })
.then(client.reload).catch((err: Error) => setActionErr(err.message))
}}
>
{CLIENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
}
/>
<Toolbar>
<Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>
{c.contacts.map((ct, i) => (
<Badge key={i}>{[ct.name, ct.email, ct.phone].filter((x) => x !== undefined && x !== '').join(' · ')}</Badge>
))}
</Toolbar>
{actionErr !== undefined && <Notice tone="err">{actionErr}</Notice>}
<h3>Modules</h3>
{modules.data !== undefined && (
<AssignModuleForm
modules={modules.data}
onAssign={(moduleId, kind) => {
setActionErr(undefined)
assignClientModule(id, { moduleId, kind })
.then(() => { cms.reload(); ledger.reload() })
.catch((err: Error) => setActionErr(err.message))
}}
/>
)}
{cms.error !== undefined && <Notice tone="err">{cms.error}</Notice>}
{cms.data === undefined || cms.data.length === 0
? <EmptyState>No modules assigned yet.</EmptyState>
: (
<DataTable
columns={[
{ key: 'module', label: 'Module' }, { key: 'kind', label: 'Kind' },
{ key: 'status', label: 'Status' },
{ key: 'installed', label: 'Installed' }, { key: 'completed', label: 'Completed' },
{ key: 'trained', label: 'Trained' },
{ key: 'billed', label: 'Billed', numeric: true },
{ key: 'settled', label: 'Settled', numeric: true },
]}
rows={cms.data.map((cm) => {
const paid = ledger.data?.modulePaid.find((mp) => mp.moduleId === cm.moduleId)
return {
module: moduleName(cm.moduleId),
kind: KIND_LABEL[cm.kind],
status: (
<ClientModuleStatusSelect
cm={cm}
onPatched={() => cms.reload()}
onError={setActionErr}
/>
),
installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
billed: paid !== undefined ? inr(paid.billedPaise) : '—',
settled: paid !== undefined ? inr(paid.settledPaise) : '—',
}
})}
/>
)}
<h3 style={{ marginTop: 20 }}>Documents</h3>
{ledger.error !== undefined && <Notice tone="err">{ledger.error}</Notice>}
{ledger.data === undefined || ledger.data.documents.length === 0
? <EmptyState>No documents yet compose one from New Document.</EmptyState>
: (
<DataTable
columns={[
{ key: 'no', label: 'No' }, { key: 'type', label: 'Type' },
{ key: 'date', label: 'Date' }, { key: 'payable', label: 'Payable', numeric: true },
{ key: 'status', label: 'Status' },
]}
onRowClick={(_row, i) => nav(`/documents/${ledger.data!.documents[i]!.id}`)}
rows={ledger.data.documents.map((d) => ({
no: d.docNo ?? <Badge tone="warn">draft</Badge>,
type: d.docType, date: d.docDate,
payable: inr(d.payablePaise),
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
}))}
/>
)}
<h3 style={{ marginTop: 20 }}>Payments &amp; dues</h3>
{ledger.data !== undefined && (
<Stats>
<StatCard label="Advance on account" value={formatINR(ledger.data.advancePaise)} hint="unallocated payments" />
</Stats>
)}
<PaymentForm
clientId={id}
onDone={() => { ledger.reload(); cms.reload() }}
/>
{ledger.data !== undefined && ledger.data.payments.length > 0 && (
<DataTable
columns={[
{ key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' },
{ key: 'ref', label: 'Reference' },
{ key: 'amount', label: 'Amount', numeric: true },
{ key: 'tds', label: 'TDS', numeric: true },
]}
rows={ledger.data.payments.map((p) => ({
on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—',
amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—',
}))}
/>
)}
</div>
)
}
function AssignModuleForm(props: { modules: Module[]; onAssign: (moduleId: string, kind: Kind) => void }) {
const [moduleId, setModuleId] = useState('')
const mod = props.modules.find((m) => m.id === moduleId)
const [kind, setKind] = useState<Kind | ''>('')
return (
<Toolbar>
<select
className="wf" value={moduleId}
onChange={(e) => { setModuleId(e.target.value); setKind('') }}
>
<option value="">Assign module</option>
{props.modules.filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
{mod !== undefined && (
<select className="wf" value={kind} onChange={(e) => setKind(e.target.value as Kind)}>
<option value="">Kind</option>
{mod.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select>
)}
<Button
tone="primary"
onClick={() => { if (moduleId !== '' && kind !== '') { props.onAssign(moduleId, kind); setModuleId(''); setKind('') } }}
>
Assign
</Button>
</Toolbar>
)
}
function ClientModuleStatusSelect(props: {
cm: ClientModule; onPatched: () => void; onError: (msg: string) => void
}) {
return (
<select
className="wf" value={props.cm.status}
onChange={(e) => {
patchClientModule(props.cm.id, { status: e.target.value as ClientModuleStatus })
.then(props.onPatched).catch((err: Error) => props.onError(err.message))
}}
>
{CLIENT_MODULE_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
)
}
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
* integer paise via fromRupees. Shared with DocumentView's "Record payment".
*/
export function PaymentForm(props: { clientId: string; onDone: () => void }) {
const [f, setF] = useState({ amountRs: '', tdsRs: '', mode: 'bank' as PaymentMode, reference: '', receivedOn: today() })
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value }))
const save = () => {
const amount = Number(f.amountRs)
if (!Number.isFinite(amount) || amount <= 0) { setError('Enter a payment amount in rupees'); return }
const tds = f.tdsRs === '' ? 0 : Number(f.tdsRs)
if (!Number.isFinite(tds) || tds < 0) { setError('TDS must be a non-negative rupee amount'); return }
setError(undefined)
setSaving(true)
recordPayment({
clientId: props.clientId, receivedOn: f.receivedOn, mode: f.mode, reference: f.reference,
amountPaise: fromRupees(amount), tdsPaise: fromRupees(tds),
})
.then(() => { setF({ amountRs: '', tdsRs: '', mode: 'bank', reference: '', receivedOn: today() }); props.onDone() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, margin: '10px 0' }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Amount (₹)"><input className="wf num" style={{ width: 120 }} value={f.amountRs} onChange={set('amountRs')} /></Field>
<Field label="TDS (₹)"><input className="wf num" style={{ width: 100 }} value={f.tdsRs} onChange={set('tdsRs')} /></Field>
<Field label="Mode">
<select className="wf" value={f.mode} onChange={set('mode')}>
{PAYMENT_MODES.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
</Field>
<Field label="Reference"><input className="wf" value={f.reference} onChange={set('reference')} /></Field>
<Field label="Received on"><input type="date" className="wf" value={f.receivedOn} onChange={set('receivedOn')} /></Field>
<Button tone="primary" onClick={save}>{saving ? 'Recording…' : 'Record payment'}</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}

@ -0,0 +1,118 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
import { createClient, getClients, type ClientStatus, type DocStatus } from '../api'
/** Tiny fetch-render hook shared by the HQ pages (same shape as backoffice live.tsx). */
export function useData<T>(loader: () => Promise<T>, deps: unknown[] = []): {
data?: T; error?: string; reload: () => void
} {
const [data, setData] = useState<T | undefined>()
const [error, setError] = useState<string | undefined>()
const reload = () => {
setError(undefined)
loader().then(setData).catch((e: Error) => setError(e.message))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(reload, deps)
return { data, error, reload }
}
type Tone = 'ok' | 'warn' | 'err' | 'accent'
export const CLIENT_TONE: Record<ClientStatus, Tone> = {
lead: 'accent', active: 'ok', dormant: 'warn', lost: 'err',
}
export const DOC_TONE: Record<DocStatus, Tone> = {
draft: 'warn', sent: 'accent', accepted: 'ok', invoiced: 'accent',
part_paid: 'warn', paid: 'ok', lost: 'err', cancelled: 'err',
}
/** Client registry — search, list, inline create; row click opens the Client 360°. */
export function Clients() {
const nav = useNavigate()
const [q, setQ] = useState('')
const { data, error, reload } = useData(() => getClients(q), [q])
const [creating, setCreating] = useState(false)
return (
<div className="wf-page">
<PageHeader
title="Clients"
desc="Every shop we serve — leads through lost. Click a row for the client 360°."
actions={<Button tone="primary" onClick={() => setCreating((v) => !v)}>New client</Button>}
/>
<Toolbar>
<input
className="wf" style={{ maxWidth: 280 }} placeholder="Search name / code / GSTIN…"
value={q} onChange={(e) => setQ(e.target.value)}
/>
<Badge>{data?.length ?? '…'} clients</Badge>
</Toolbar>
{creating && (
<NewClientForm onCreated={(id) => { setCreating(false); reload(); nav(`/clients/${id}`) }} />
)}
{error !== undefined && <Notice tone="err">{error}</Notice>}
{data === undefined ? <EmptyState>Loading</EmptyState>
: data.length === 0 ? <EmptyState>No clients{q !== '' ? ` matching “${q}` : ' yet — add the first one'}.</EmptyState> : (
<DataTable
columns={[
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' },
{ key: 'status', label: 'Status' }, { key: 'state', label: 'State' },
{ key: 'contact', label: 'Contact' },
]}
onRowClick={(_row, i) => nav(`/clients/${data[i]!.id}`)}
rows={data.map((c) => ({
code: c.code, name: c.name,
status: <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>,
state: c.stateCode,
contact: c.contacts[0]?.email ?? c.contacts[0]?.phone ?? '—',
}))}
/>
)}
</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 style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<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>
)
}

@ -0,0 +1,193 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, Notice, PageHeader, Toolbar } from '@sims/ui'
import {
documentAction, fetchPdfBlob, getClient, getDocumentFull,
type Client, type Doc,
} from '../api'
import { DOC_TONE, useData } from './Clients'
import { PaymentForm } from './ClientDetail'
const TITLE: Record<Doc['docType'], string> = {
QUOTATION: 'Quotation', PROFORMA: 'Proforma Invoice', INVOICE: 'Tax Invoice',
RECEIPT: 'Receipt', CREDIT_NOTE: 'Credit Note',
}
/** Friendly text for the send-route's 409s (the spec's token-death banner). */
function friendly(msg: string): string {
if (msg === 'gmail-token-dead') return 'Gmail token dead — reconnect by running gmail-connect on the server, then retry.'
if (msg === 'gmail-not-connected') return 'Gmail is not connected — run gmail-connect on the server first.'
return msg
}
/** Document page: PDF preview, state-driven action bar, event timeline + email log. */
export function DocumentView() {
const id = useParams()['id'] ?? ''
const nav = useNavigate()
const full = useData(() => getDocumentFull(id), [id])
const doc = full.data?.document
const client = useData<Client | undefined>(
() => (doc !== undefined ? getClient(doc.clientId) : Promise.resolve(undefined)),
[doc?.clientId],
)
const [actionErr, setActionErr] = useState<string | undefined>()
const [busy, setBusy] = useState<string | undefined>()
const [paying, setPaying] = useState(false)
// PDF preview via blob URL — an <iframe src> can't carry the bearer header.
const [pdfUrl, setPdfUrl] = useState<string | undefined>()
const [pdfErr, setPdfErr] = useState<string | undefined>()
useEffect(() => {
if (doc === undefined) return
let alive = true
let url: string | undefined
setPdfErr(undefined)
fetchPdfBlob(id)
.then((blob) => {
if (!alive) return
url = URL.createObjectURL(blob)
setPdfUrl(url)
})
.catch((e: Error) => { if (alive) setPdfErr(e.message) })
return () => {
alive = false
if (url !== undefined) URL.revokeObjectURL(url)
}
}, [id, doc?.docNo, doc?.status])
if (full.error !== undefined) return <Notice tone="err">{full.error}</Notice>
if (doc === undefined || full.data === undefined) return <EmptyState>Loading</EmptyState>
/** POST an action; convert/credit-note navigate to the new document. */
const act = (action: string, body?: Record<string, unknown>, navigateToResult = false) => {
setActionErr(undefined)
setBusy(action)
documentAction(id, action, body)
.then((out) => {
if (navigateToResult && out.id !== id) nav(`/documents/${out.id}`)
else full.reload()
})
.catch((e: Error) => setActionErr(friendly(e.message)))
.finally(() => setBusy(undefined))
}
const send = () => {
const defaultTo = client.data?.contacts.find((ct) => ct.email !== undefined && ct.email !== '')?.email ?? ''
const to = window.prompt('Send to (email)', defaultTo)
if (to === null || to.trim() === '') return
const subject = window.prompt('Subject', `${doc.docType} ${doc.docNo ?? ''}`.trim())
if (subject === null) return
act('send', { to: to.trim(), subject })
}
const issued = doc.docNo !== null
const cancelled = doc.status === 'cancelled'
const settling = doc.status === 'part_paid' || doc.status === 'paid'
return (
<div className="wf-page">
<PageHeader
title={`${TITLE[doc.docType]} ${doc.docNo ?? '(draft)'}`}
badge={doc.fy}
desc={`${client.data?.name ?? '…'} · ${doc.docDate} · Payable ${formatINR(doc.payablePaise)}`}
actions={<Badge tone={DOC_TONE[doc.status]}>{doc.status}</Badge>}
/>
<Toolbar>
{!issued && !cancelled && (
<Button tone="primary" onClick={() => act('issue')}>
{busy === 'issue' ? 'Issuing…' : 'Issue number'}
</Button>
)}
{issued && !cancelled && (
<Button tone="primary" onClick={send}>{busy === 'send' ? 'Sending…' : 'Send by email'}</Button>
)}
{issued && doc.status === 'draft' && (
<Button onClick={() => act('status', { status: 'sent' })}>Mark sent (manual)</Button>
)}
{doc.docType === 'QUOTATION' && doc.status === 'sent' && (
<>
<Button onClick={() => act('status', { status: 'accepted' })}>Mark accepted</Button>
<Button onClick={() => act('status', { status: 'lost' })}>Mark lost</Button>
</>
)}
{doc.docType === 'QUOTATION' && !cancelled && (
<Button onClick={() => act('convert', { to: 'PROFORMA' }, true)}>Convert to Proforma</Button>
)}
{(doc.docType === 'QUOTATION' || doc.docType === 'PROFORMA') && !cancelled && (
<Button onClick={() => act('convert', { to: 'INVOICE' }, true)}>Convert to Invoice</Button>
)}
{doc.docType === 'INVOICE' && issued && !cancelled && doc.status !== 'paid' && (
<Button tone="primary" onClick={() => setPaying((v) => !v)}>Record payment</Button>
)}
{issued && !cancelled && !settling && (
<Button
tone="danger"
onClick={() => { if (window.confirm(`Cancel ${doc.docNo}? The number stays consumed.`)) act('cancel') }}
>
Cancel
</Button>
)}
{doc.docType === 'INVOICE' && issued && !cancelled && (
<Button
onClick={() => {
if (window.confirm(`Create a full-value credit note against ${doc.docNo}?`)) {
act('credit-note', {}, true)
}
}}
>
Credit note
</Button>
)}
</Toolbar>
{actionErr !== undefined && <Notice tone="err">{actionErr}</Notice>}
{paying && (
<PaymentForm
clientId={doc.clientId}
onDone={() => { setPaying(false); full.reload() }}
/>
)}
{pdfErr !== undefined && <Notice tone="warn">PDF preview unavailable: {pdfErr}</Notice>}
{pdfUrl !== undefined && (
<iframe
src={pdfUrl} title="Document PDF"
style={{ width: '100%', height: 560, border: '1px solid var(--border)', borderRadius: 8, background: '#fff' }}
/>
)}
<h3 style={{ marginTop: 20 }}>Timeline</h3>
{full.data.events.length === 0 ? <EmptyState>No events.</EmptyState> : (
<DataTable
columns={[
{ key: 'at', label: 'When' }, { key: 'kind', label: 'Event' }, { key: 'meta', label: 'Detail' },
]}
rows={full.data.events.map((ev) => ({
at: new Date(ev.atWall).toLocaleString(),
kind: <Badge tone="accent">{ev.kind}</Badge>,
meta: Object.keys(ev.meta).length > 0 ? JSON.stringify(ev.meta) : '—',
}))}
/>
)}
<h3 style={{ marginTop: 20 }}>Email log</h3>
{full.data.emails.length === 0 ? <EmptyState>Not emailed yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'at', label: 'When' }, { key: 'to', label: 'To' },
{ key: 'subject', label: 'Subject' }, { key: 'status', label: 'Status' },
{ key: 'error', label: 'Error' },
]}
rows={full.data.emails.map((e) => ({
at: new Date(e.at_wall).toLocaleString(),
to: e.to_addr, subject: e.subject,
status: <Badge tone={e.status === 'sent' ? 'ok' : 'err'}>{e.status}</Badge>,
error: e.error ?? '—',
}))}
/>
)}
</div>
)
}

@ -0,0 +1,157 @@
import { useState } from 'react'
import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
import {
addPrice, createModule, getModules, getPrices, role,
KIND_LABEL, type Kind, type Module,
} from '../api'
import { useData } from './Clients'
const ALL_KINDS: Kind[] = ['one_time', 'monthly', 'yearly', 'usage']
const today = () => new Date().toISOString().slice(0, 10)
/** Module catalog + dated price book. Owner edits; staff read-only. */
export function Modules() {
const isOwner = role() === 'owner'
const modules = useData(getModules, [])
const [creating, setCreating] = useState(false)
const [selected, setSelected] = useState<Module | undefined>()
return (
<div className="wf-page">
<PageHeader
title="Modules"
desc="What we sell — each with a dated price book (later rows win from their effective date)."
actions={isOwner
? <Button tone="primary" onClick={() => setCreating((v) => !v)}>New module</Button>
: <Badge>read-only (staff)</Badge>}
/>
{creating && isOwner && (
<NewModuleForm onCreated={() => { setCreating(false); modules.reload() }} />
)}
{modules.error !== undefined && <Notice tone="err">{modules.error}</Notice>}
{modules.data === undefined ? <EmptyState>Loading</EmptyState>
: modules.data.length === 0 ? <EmptyState>No modules yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' },
{ key: 'sac', label: 'SAC' }, { key: 'kinds', label: 'Billing kinds' },
{ key: 'multi', label: 'Multi-sub' }, { key: 'active', label: 'Active' },
]}
onRowClick={(_row, i) => setSelected(modules.data![i])}
rows={modules.data.map((m) => ({
code: m.code, name: m.name, sac: m.sac,
kinds: m.allowedKinds.map((k) => KIND_LABEL[k]).join(' · '),
multi: m.multiSubscription ? <Badge tone="accent">yes</Badge> : '—',
active: m.active ? <Badge tone="ok">active</Badge> : <Badge>inactive</Badge>,
}))}
/>
)}
{selected !== undefined && (
<PriceBook module={selected} isOwner={isOwner} />
)}
</div>
)
}
function NewModuleForm(props: { onCreated: () => void }) {
const [f, setF] = useState({ code: '', name: '', sac: '998313' })
const [kinds, setKinds] = useState<Kind[]>([...ALL_KINDS])
const [multi, setMulti] = useState(false)
const [error, setError] = useState<string | undefined>()
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value }))
const toggleKind = (k: Kind) =>
setKinds((prev) => (prev.includes(k) ? prev.filter((x) => x !== k) : [...prev, k]))
const save = () => {
if (f.code.trim() === '' || f.name.trim() === '') { setError('Code and name are required'); return }
if (kinds.length === 0) { setError('Pick at least one billing kind'); return }
createModule({
code: f.code.trim().toUpperCase(), name: f.name.trim(), sac: f.sac.trim(),
allowedKinds: ALL_KINDS.filter((k) => kinds.includes(k)), multiSubscription: multi,
})
.then(props.onCreated)
.catch((e: Error) => setError(e.message))
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Code"><input className="wf" style={{ width: 100 }} value={f.code} autoFocus onChange={set('code')} /></Field>
<Field label="Name"><input className="wf" value={f.name} onChange={set('name')} /></Field>
<Field label="SAC"><input className="wf" style={{ width: 100 }} value={f.sac} onChange={set('sac')} /></Field>
<Field label="Billing kinds">
<div style={{ display: 'flex', gap: 10 }}>
{ALL_KINDS.map((k) => (
<label key={k} style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" checked={kinds.includes(k)} onChange={() => toggleKind(k)} />
{KIND_LABEL[k]}
</label>
))}
</div>
</Field>
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" checked={multi} onChange={() => setMulti((v) => !v)} />
multi-subscription
</label>
<Button tone="primary" onClick={save}>Save module</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}
function PriceBook(props: { module: Module; isOwner: boolean }) {
const prices = useData(() => getPrices(props.module.id), [props.module.id])
const [f, setF] = useState({ kind: '' as Kind | '', edition: 'standard', priceRs: '', effectiveFrom: today() })
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 = () => {
const rs = Number(f.priceRs)
if (f.kind === '') { setError('Pick a kind'); return }
if (!Number.isFinite(rs) || rs < 0) { setError('Enter the price in rupees'); return }
setError(undefined)
addPrice(props.module.id, {
kind: f.kind, edition: f.edition.trim() !== '' ? f.edition.trim() : 'standard',
pricePaise: fromRupees(rs), effectiveFrom: f.effectiveFrom,
})
.then(() => { setF((p) => ({ ...p, priceRs: '' })); prices.reload() })
.catch((e: Error) => setError(e.message))
}
return (
<>
<h3 style={{ marginTop: 20 }}>Prices {props.module.name}</h3>
{props.isOwner && (
<Toolbar>
<select className="wf" value={f.kind} onChange={set('kind')}>
<option value="">Kind</option>
{props.module.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select>
<input className="wf" style={{ width: 110 }} placeholder="edition" value={f.edition} onChange={set('edition')} />
<input className="wf num" style={{ width: 110 }} placeholder="₹" value={f.priceRs} onChange={set('priceRs')} />
<input type="date" className="wf" value={f.effectiveFrom} onChange={set('effectiveFrom')} />
<Button tone="primary" onClick={save}>Add price</Button>
</Toolbar>
)}
{error !== undefined && <Notice tone="err">{error}</Notice>}
{prices.error !== undefined && <Notice tone="err">{prices.error}</Notice>}
{prices.data === undefined ? <EmptyState>Loading</EmptyState>
: prices.data.length === 0 ? <EmptyState>No prices yet{props.isOwner ? ' — add the first row above' : ''}.</EmptyState> : (
<DataTable
columns={[
{ key: 'kind', label: 'Kind' }, { key: 'edition', label: 'Edition' },
{ key: 'price', label: 'Price', numeric: true }, { key: 'from', label: 'Effective from' },
]}
rows={prices.data.map((p) => ({
kind: KIND_LABEL[p.kind], edition: p.edition,
price: formatINR(p.pricePaise), from: p.effectiveFrom,
}))}
/>
)}
</>
)
}

@ -0,0 +1,214 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
import {
createDocument, getClients, getModules, getPrices,
KIND_LABEL, type Client, type Kind, type ModulePrice,
} from '../api'
import { useData } from './Clients'
const today = () => new Date().toISOString().slice(0, 10)
interface LineDraft {
moduleId: string; kind: Kind | ''; qty: string; unitRs: string; description: string
}
const EMPTY_LINE: LineDraft = { moduleId: '', kind: '', qty: '1', unitRs: '', description: '' }
/** Latest effective price for kind+edition on/before today — mirrors the server's priceOn. */
function latestPrice(prices: ModulePrice[], kind: Kind, edition = 'standard'): number | null {
const hit = prices
.filter((p) => p.kind === kind && p.edition === edition && p.effectiveFrom <= today())
.sort((a, b) => (a.effectiveFrom < b.effectiveFrom ? 1 : -1))[0]
return hit !== undefined ? hit.pricePaise : null
}
/** The quotation-in-minutes composer: client → lines → Save Draft (server computes GST). */
export function NewDocument() {
const nav = useNavigate()
const modules = useData(getModules, [])
const [docType, setDocType] = useState<'QUOTATION' | 'PROFORMA' | 'INVOICE'>('QUOTATION')
const [lines, setLines] = useState<LineDraft[]>([{ ...EMPTY_LINE }])
const [terms, setTerms] = useState('')
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
// -- client type-ahead (debounced) --
const [q, setQ] = useState('')
const [hits, setHits] = useState<Client[]>([])
const [client, setClient] = useState<Client | undefined>()
useEffect(() => {
if (client !== undefined || q.trim() === '') { setHits([]); return }
const t = setTimeout(() => {
getClients(q).then(setHits).catch(() => setHits([]))
}, 250)
return () => clearTimeout(t)
}, [q, client])
// -- per-module price cache for unit-₹ prefill --
const priceCache = useRef(new Map<string, ModulePrice[]>())
const prefill = (index: number, moduleId: string, kind: Kind) => {
const apply = (prices: ModulePrice[]) => {
const paise = latestPrice(prices, kind)
setLines((prev) => prev.map((l, i) =>
i === index ? { ...l, unitRs: paise !== null ? String(paise / 100) : l.unitRs } : l))
}
const cached = priceCache.current.get(moduleId)
if (cached !== undefined) { apply(cached); return }
getPrices(moduleId)
.then((prices) => { priceCache.current.set(moduleId, prices); apply(prices) })
.catch(() => { /* no prefill — the unit price stays editable */ })
}
const setLine = (index: number, patch: Partial<LineDraft>) =>
setLines((prev) => prev.map((l, i) => (i === index ? { ...l, ...patch } : l)))
// Client-side preview only — the server's computeBill is authoritative for tax.
const subtotalPaise = lines.reduce((sum, l) => {
const qty = Number(l.qty)
const rs = Number(l.unitRs)
return Number.isFinite(qty) && Number.isFinite(rs) && l.moduleId !== '' ? sum + Math.round(qty * fromRupees(rs)) : sum
}, 0)
const save = () => {
if (client === undefined) { setError('Pick a client first'); return }
const ready = lines.filter((l) => l.moduleId !== '' && l.kind !== '')
if (ready.length === 0) { setError('Add at least one line (module + kind)'); return }
for (const l of ready) {
if (!Number.isFinite(Number(l.qty)) || Number(l.qty) <= 0) { setError('Every line needs a positive quantity'); return }
if (l.unitRs !== '' && !Number.isFinite(Number(l.unitRs))) { setError('Unit price must be a rupee amount'); return }
}
setError(undefined)
setSaving(true)
createDocument({
docType,
clientId: client.id,
lines: ready.map((l) => ({
moduleId: l.moduleId, kind: l.kind, qty: Number(l.qty),
...(l.unitRs !== '' ? { unitPricePaise: fromRupees(Number(l.unitRs)) } : {}),
...(l.description !== '' ? { description: l.description } : {}),
})),
...(terms.trim() !== '' ? { terms: terms.trim() } : {}),
})
.then((d) => nav(`/documents/${d.id}`))
.catch((e: Error) => { setError(e.message); setSaving(false) })
}
return (
<div className="wf-page">
<PageHeader
title="New Document"
desc="Compose a quotation, proforma or invoice. GST is computed server-side on save."
/>
<Field label="Client">
{client !== undefined ? (
<Toolbar>
<Badge tone="accent">{client.code} · {client.name}</Badge>
<Button onClick={() => { setClient(undefined); setQ('') }}>Change</Button>
</Toolbar>
) : (
<div style={{ position: 'relative', maxWidth: 340 }}>
<input
className="wf" placeholder="Type to search clients…" value={q} autoFocus
onChange={(e) => setQ(e.target.value)}
/>
{hits.length > 0 && (
<div style={{
position: 'absolute', zIndex: 5, top: '100%', left: 0, right: 0,
background: 'var(--bg-raised)', border: '1px solid var(--border)', borderRadius: 8,
}}>
{hits.slice(0, 8).map((c) => (
<button
key={c.id} type="button" className="wf"
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none' }}
onClick={() => { setClient(c); setHits([]) }}
>
{c.code} · {c.name}
</button>
))}
</div>
)}
</div>
)}
</Field>
<Field label="Document type">
<Toolbar>
{(['QUOTATION', 'PROFORMA', 'INVOICE'] as const).map((t) => (
<label key={t} style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
<input type="radio" name="doctype" checked={docType === t} onChange={() => setDocType(t)} />
{t === 'QUOTATION' ? 'Quotation' : t === 'PROFORMA' ? 'Proforma' : 'Invoice'}
</label>
))}
</Toolbar>
</Field>
<h3>Lines</h3>
{lines.map((l, i) => {
const mod = modules.data?.find((m) => m.id === l.moduleId)
return (
<div key={i} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
<Field label="Module">
<select
className="wf" value={l.moduleId}
onChange={(e) => {
const moduleId = e.target.value
setLine(i, { moduleId, kind: '', unitRs: '' })
}}
>
<option value="">Module</option>
{(modules.data ?? []).filter((m) => m.active).map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</Field>
<Field label="Kind">
<select
className="wf" value={l.kind}
onChange={(e) => {
const kind = e.target.value as Kind
setLine(i, { kind })
if (l.moduleId !== '') prefill(i, l.moduleId, kind)
}}
>
<option value="">Kind</option>
{(mod?.allowedKinds ?? []).map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select>
</Field>
<Field label="Qty">
<input className="wf num" style={{ width: 70 }} value={l.qty} onChange={(e) => setLine(i, { qty: e.target.value })} />
</Field>
<Field label="Unit ₹ (ex-GST)">
<input className="wf num" style={{ width: 120 }} value={l.unitRs} onChange={(e) => setLine(i, { unitRs: e.target.value })} />
</Field>
<Field label="Description (optional)">
<input className="wf" value={l.description} onChange={(e) => setLine(i, { description: e.target.value })} />
</Field>
{lines.length > 1 && (
<Button tone="danger" onClick={() => setLines((prev) => prev.filter((_x, j) => j !== i))}>Remove</Button>
)}
</div>
)
})}
<Toolbar>
<Button onClick={() => setLines((prev) => [...prev, { ...EMPTY_LINE }])}>Add line</Button>
<Badge tone="accent">Subtotal {formatINR(subtotalPaise)} + GST (server-computed)</Badge>
</Toolbar>
<Field label="Terms (optional)">
<textarea
className="wf" rows={2} style={{ maxWidth: 560 }}
value={terms} onChange={(e) => setTerms(e.target.value)}
/>
</Field>
{modules.error !== undefined && <Notice tone="err">{modules.error}</Notice>}
{error !== undefined && <Notice tone="err">{error}</Notice>}
<div style={{ marginTop: 10 }}>
<Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save Draft'}</Button>
</div>
</div>
)
}
Loading…
Cancel
Save