You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/pages/Modules.tsx

509 lines
23 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useEffect, useMemo, useState } from 'react'
import { formatINR, fromRupees } from '@sims/domain'
import {
Badge, Button, ConfirmDialog, DataTable, Dialog, EmptyState, ErrorState, Field, FormField,
FormGrid, Notice, PageHeader, Skeleton, Toolbar, useToast,
} from '@sims/ui'
import {
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'
const ALL_KINDS: Kind[] = ['one_time', 'monthly', 'yearly', 'usage']
const today = () => new Date().toISOString().slice(0, 10)
/** Textarea ↔ string[]: one "what's included" bullet per row, blanks dropped. */
const splitLines = (s: string): string[] => s.split('\n').map((x) => x.trim()).filter((x) => x !== '')
/** 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(true)}>New module</Button>
: <Badge>read-only (staff)</Badge>}
/>
{modules.error !== undefined ? <ErrorState message={modules.error} onRetry={modules.reload} />
: modules.data === undefined ? <Skeleton rows={5} />
: modules.data.length === 0 ? <EmptyState>No modules yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'code', label: 'Code', mono: true }, { 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 && (
<>
<ModuleClients key={`mc-${selected.id}`} module={selected} />
<QuoteContent key={selected.id} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
<PriceBook module={selected} isOwner={isOwner} />
</>
)}
{isOwner && (
<NewModuleDialog
open={creating}
onClose={() => setCreating(false)}
onCreated={() => { setCreating(false); modules.reload() }}
/>
)}
</div>
)
}
/**
* "Who's on this module" roster (spec §10): every ACTIVE client link with what
* it pays and its next renewal, plus Notify-all (owner/manager) and CSV export.
*/
function ModuleClients(props: { module: Module }) {
const managerial = role() === 'owner' || role() === 'manager'
const [page, setPage] = useState(1)
const roster = useData(() => getModuleClients(props.module.id, page), [props.module.id, page])
const [notifying, setNotifying] = useState(false)
const [subject, setSubject] = useState('')
const [bodyText, setBodyText] = useState('')
const [busy, setBusy] = useState(false)
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 })
.then((out) => {
setResult(`Sent ${out.sent}/${out.total}`
+ (out.failed.length > 0 ? ` — unreached: ${out.failed.map((f) => `${f.client} (${f.error})`).join(', ')}` : ''))
setNotifying(false)
})
.catch((e: Error) => setErr(e.message))
.finally(() => setBusy(false))
}
const data = roster.data
const pages = data !== undefined ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1
return (
<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})` : ''}`}
</Button>
)}
<Button onClick={() => { downloadModuleClientsCsv(props.module.id, props.module.code).catch((e: Error) => setErr(e.message)) }}>
Export CSV
</Button>
</Toolbar>
{assigning && (
// wf-card, not the dead 'wf-form' class (no rules exist for it anywhere) —
// the assign panel gets the same contained-card chrome as every other inline panel.
<div className="wf-card">
<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-card">
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} /></Field>
<Field label="Message">
<textarea rows={4} value={bodyText} onChange={(e) => setBodyText(e.target.value)} />
</Field>
<Button
tone="primary"
onClick={() => { if (!busy && subject.trim() !== '' && bodyText.trim() !== '') doNotify() }}
>
{busy ? 'Sending…' : `Send to every client on ${props.module.code}`}
</Button>
</div>
)}
{result !== undefined && <Notice tone="ok">{result}</Notice>}
{err !== undefined && <Notice tone="err">{err}</Notice>}
{roster.error !== undefined ? <ErrorState message={roster.error} onRetry={roster.reload} />
: data === undefined ? <Skeleton rows={4} />
: data.total === 0 ? <EmptyState>No client is on this module yet.</EmptyState> : (
<>
<DataTable
columns={[
{ 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) => {
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" disabled={busy} 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 && (
<>
<Button onClick={() => { if (page > 1) setPage((p) => p - 1) }}> Prev</Button>
<span>Page {data.page}/{pages}</span>
<Button onClick={() => { if (page < pages) setPage((p) => p + 1) }}>Next </Button>
</>
)}
</div>
</>
)}
</section>
)
}
/** Per-module "what's included" lines — prefilled into the composer, printed under the quote line. */
function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () => void }) {
const toast = useToast()
const [text, setText] = useState(props.module.quoteContent.join('\n'))
const [error, setError] = useState<string | undefined>()
const [commitNonce, setCommitNonce] = useState(0)
const previewBody = useMemo(() => JSON.stringify({ contentLines: splitLines(text) }), [text])
const save = () => {
setError(undefined)
patchModule(props.module.id, { quoteContent: splitLines(text) })
.then(() => { toast.ok('Quote content saved'); props.onSaved() })
.catch((e: Error) => setError(e.message))
}
return (
<>
<h3 style={{ marginTop: 20 }}>Quote content {props.module.name}</h3>
{props.isOwner ? (
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ flex: '0 0 560px', minWidth: 0 }}>
<textarea className="wf" rows={4} placeholder="One included item per line…"
value={text} onChange={(e) => setText(e.target.value)} onBlur={() => setCommitNonce((n) => n + 1)} />
<Toolbar>
<Button tone="primary" onClick={save}>Save quote content</Button>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
<div style={{ flex: 1, minWidth: 320, height: 420, background: 'var(--bg-inset)', borderRadius: 8, padding: 12 }}>
<div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
<LivePreview
body={previewBody}
commitNonce={commitNonce}
fetchPreview={(b, signal) => previewSample(JSON.parse(b), signal).then((r) => ({ html: r.html }))}
/>
</div>
</div>
</div>
) : props.module.quoteContent.length === 0 ? (
<EmptyState>No quote content.</EmptyState>
) : (
<ul>{props.module.quoteContent.map((line, i) => <li key={i}>{line}</li>)}</ul>
)}
</>
)
}
function NewModuleDialog(props: { open: boolean; onClose: () => void; onCreated: () => void }) {
const toast = useToast()
const [f, setF] = useState({ code: '', name: '', sac: '998313' })
const [kinds, setKinds] = useState<Kind[]>([...ALL_KINDS])
const [multi, setMulti] = useState(false)
const [quoteText, setQuoteText] = useState('')
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!props.open) return
setError(undefined)
setF({ code: '', name: '', sac: '998313' })
setKinds([...ALL_KINDS])
setMulti(false)
setQuoteText('')
}, [props.open])
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 (saving) return
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 }
setError(undefined)
setSaving(true)
createModule({
code: f.code.trim().toUpperCase(), name: f.name.trim(), sac: f.sac.trim(),
allowedKinds: ALL_KINDS.filter((k) => kinds.includes(k)), multiSubscription: multi,
quoteContent: splitLines(quoteText),
})
.then(() => { toast.ok('Module created'); props.onCreated() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
}
return (
<Dialog
open={props.open}
onClose={() => { if (!saving) props.onClose() }}
title="New module"
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Save module'}</Button>
</>
}
>
<FormGrid>
<FormField label="Code"><input className="wf" value={f.code} autoFocus onChange={set('code')} /></FormField>
<FormField label="Name"><input className="wf" value={f.name} onChange={set('name')} /></FormField>
<FormField label="SAC"><input className="wf" value={f.sac} onChange={set('sac')} /></FormField>
<FormField label="Billing kinds">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{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>
</FormField>
<FormField label="Multi-subscription">
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" checked={multi} onChange={() => setMulti((v) => !v)} />
multi-subscription
</label>
</FormField>
<FormField label="Quote content (one per line, optional)" wide>
<textarea className="wf" rows={3} value={quoteText} onChange={(e) => setQuoteText(e.target.value)} />
</FormField>
</FormGrid>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</Dialog>
)
}
function PriceBook(props: { module: Module; isOwner: boolean }) {
const prices = useData(() => getPrices(props.module.id), [props.module.id])
const [adding, setAdding] = useState(false)
return (
<>
<h3 style={{ marginTop: 20 }}>Prices {props.module.name}</h3>
{props.isOwner && (
<Toolbar>
<Button tone="primary" onClick={() => setAdding(true)}>Add price</Button>
</Toolbar>
)}
{prices.error !== undefined ? <ErrorState message={prices.error} onRetry={prices.reload} />
: prices.data === undefined ? <Skeleton rows={5} />
: prices.data.length === 0 ? <EmptyState>No prices yet{props.isOwner ? ' — add the first one 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,
}))}
/>
)}
{props.isOwner && (
<AddPriceDialog
open={adding}
onClose={() => setAdding(false)}
module={props.module}
onAdded={() => { setAdding(false); prices.reload() }}
/>
)}
</>
)
}
function AddPriceDialog(props: { open: boolean; onClose: () => void; module: Module; onAdded: () => void }) {
const toast = useToast()
const [f, setF] = useState({ kind: '' as Kind | '', edition: 'standard', priceRs: '', effectiveFrom: today() })
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!props.open) return
setError(undefined)
setF({ kind: '', edition: 'standard', priceRs: '', effectiveFrom: today() })
}, [props.open, props.module.id])
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value }))
const save = () => {
if (saving) return
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)
setSaving(true)
addPrice(props.module.id, {
kind: f.kind, edition: f.edition.trim() !== '' ? f.edition.trim() : 'standard',
pricePaise: fromRupees(rs), effectiveFrom: f.effectiveFrom,
})
.then(() => { toast.ok('Price added'); props.onAdded() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
}
return (
<Dialog
open={props.open}
onClose={() => { if (!saving) props.onClose() }}
title={`Add price — ${props.module.name}`}
size="sm"
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Add price'}</Button>
</>
}
>
<FormGrid>
<FormField label="Kind">
<select className="wf" autoFocus 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>
</FormField>
<FormField label="Edition"><input className="wf" value={f.edition} onChange={set('edition')} /></FormField>
<FormField label="Price (₹)"><input className="wf num" value={f.priceRs} onChange={set('priceRs')} /></FormField>
<FormField label="Effective from"><input type="date" className="wf" value={f.effectiveFrom} onChange={set('effectiveFrom')} /></FormField>
</FormGrid>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</Dialog>
)
}