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

206 lines
9.3 KiB
TypeScript

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, patchModule, 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)
/** 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((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 && (
<>
<QuoteContent key={selected.id} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
<PriceBook module={selected} isOwner={isOwner} />
</>
)}
</div>
)
}
/** 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 [text, setText] = useState(props.module.quoteContent.join('\n'))
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | undefined>()
const save = () => {
setError(undefined)
patchModule(props.module.id, { quoteContent: splitLines(text) })
.then(() => { setSaved(true); props.onSaved() })
.catch((e: Error) => setError(e.message))
}
return (
<>
<h3 style={{ marginTop: 20 }}>Quote content {props.module.name}</h3>
{props.isOwner ? (
<div style={{ maxWidth: 560 }}>
<textarea
className="wf" rows={4} placeholder="One included item per line…"
value={text} onChange={(e) => { setText(e.target.value); setSaved(false) }}
/>
<Toolbar>
<Button tone="primary" onClick={save}>Save quote content</Button>
{saved && <Badge tone="ok">saved</Badge>}
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</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 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 [quoteText, setQuoteText] = useState('')
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,
quoteContent: splitLines(quoteText),
})
.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>
<Field label="Quote content (one per line, optional)">
<textarea className="wf" rows={2} style={{ minWidth: 220 }} value={quoteText} onChange={(e) => setQuoteText(e.target.value)} />
</Field>
<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,
}))}
/>
)}
</>
)
}