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

389 lines
16 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, 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,
} 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>()
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>
{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>
{notifying && managerial && (
<div className="wf-form">
<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' },
]}
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 ?? '—',
}))}
/>
<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" 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" 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>
)
}