|
|
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>
|
|
|
)
|
|
|
}
|