feat(hq-web): AMC/plan/interaction/employee/module dialogs — edit everywhere, confirmed deactivations

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 59c41ee8f7
commit 1a8a3faf5d

@ -2,8 +2,8 @@ import { useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
Badge, Button, DataTable, EmptyState, ErrorState, RecordHeader, Skeleton,
StatCard, Stats, Tabs, Toolbar,
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, RecordHeader, Skeleton,
StatCard, Stats, Tabs, Toolbar, useToast,
} from '@sims/ui'
import {
assignClientModule, getClient, getClientModules, getLedger, getModules,
@ -13,12 +13,13 @@ import {
getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage,
CLIENT_STATUSES, KIND_LABEL,
type AmcPaidStatus, type ClientStatus, type Outcome,
type AmcContract, type AmcPaidStatus, type ClientStatus, type Interaction, type Outcome,
type RecurringPlan,
} from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
import {
AccountOwnerSelect, AssignModuleForm, ClientModuleStatusSelect,
LogInteractionForm, NewAmcForm, NewRecurringForm, PaymentForm, RowDate,
AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect,
InteractionDialog, PaymentForm, PlanDialog, RowDate,
} from './client-forms'
import { ClientFormDialog } from '../components/ClientFormDialog'
import { PulseRibbon } from '../components/PulseRibbon'
@ -32,6 +33,10 @@ const OUTCOME_TONE: Record<Outcome, 'ok' | 'warn' | 'err'> = { positive: 'ok', n
const TAB_KEYS = ['overview', 'modules', 'documents', 'payments', 'amc', 'interactions', 'aws'] as const
type TabKey = (typeof TAB_KEYS)[number]
/** One pending confirmation replaces the browser confirm() popup with the styled dialog
* (same pattern as DocumentView Task 5). */
interface Confirm { title: string; body: string; label: string; tone?: 'danger'; run: () => void | Promise<void> }
/** Client 360° — record header, KPIs, pulse ribbon (Task 10), tabbed sections. */
export function ClientDetail() {
const id = useParams()['id'] ?? ''
@ -49,8 +54,13 @@ export function ClientDetail() {
const employees = useData(() => getEmployees(), [])
const isOwner = role() === 'owner'
const canRoute = isManagerial()
const toast = useToast()
const [actionErr, setActionErr] = useState<string | undefined>()
const [editing, setEditing] = useState(false)
const [amcDialog, setAmcDialog] = useState<{ initial?: AmcContract } | undefined>()
const [planDialog, setPlanDialog] = useState<{ initial?: RecurringPlan } | undefined>()
const [interactionDialog, setInteractionDialog] = useState<{ initial?: Interaction } | undefined>()
const [confirm, setConfirm] = useState<Confirm | undefined>()
const rawTab = sp.get('tab') ?? 'overview'
const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview'
@ -294,13 +304,9 @@ export function ClientDetail() {
)}
<h3 style={{ marginTop: 20 }}>Recurring plans</h3>
{isOwner && cms.data !== undefined && (
<NewRecurringForm
clientId={id}
options={(cms.data ?? []).filter((cm) => cm.active).map((cm) => ({
id: cm.id, label: `${moduleName(cm.moduleId)} · ${KIND_LABEL[cm.kind]}`,
}))}
onDone={() => plans.reload()}
/>
<Toolbar>
<Button tone="primary" onClick={() => setPlanDialog({})}>New recurring plan</Button>
</Toolbar>
)}
{plans.error !== undefined && <ErrorState message={plans.error} onRetry={plans.reload} />}
{plans.data === undefined && plans.error === undefined ? <Skeleton />
@ -320,23 +326,44 @@ export function ClientDetail() {
next: plan.nextRun,
policy: <Badge tone={plan.policy === 'auto' ? 'accent' : 'warn'}>{plan.policy}</Badge>,
active: plan.active ? 'yes' : 'no',
act: plan.active ? (
<Button onClick={() => {
setActionErr(undefined)
deactivateRecurringPlan(plan.id)
.then(() => plans.reload()).catch((err: Error) => setActionErr(err.message))
}}>Deactivate</Button>
) : '—',
act: (
<div style={{ display: 'flex', gap: 6 }}>
{isOwner && <Button onClick={() => setPlanDialog({ initial: plan })}>Edit</Button>}
{plan.active && (
<Button tone="danger" onClick={() => setConfirm({
title: 'Deactivate recurring plan',
body: `Deactivate the ${plan.cadence} recurring plan${cm !== undefined ? ` for ${moduleName(cm.moduleId)}` : ''}?`,
label: 'Deactivate',
tone: 'danger',
run: () => deactivateRecurringPlan(plan.id).then(() => { toast.ok('Plan deactivated'); plans.reload() }),
})}>Deactivate</Button>
)}
</div>
),
}
})}
/>
)}
<PlanDialog
open={planDialog !== undefined}
onClose={() => setPlanDialog(undefined)}
clientId={id}
options={(cms.data ?? []).filter((cm) => cm.active).map((cm) => ({
id: cm.id, label: `${moduleName(cm.moduleId)} · ${KIND_LABEL[cm.kind]}`,
}))}
initial={planDialog?.initial}
onDone={() => plans.reload()}
/>
</>
)}
{tab === 'amc' && (
<>
{isOwner && <NewAmcForm clientId={id} onDone={() => amc.reload()} />}
{isOwner && (
<Toolbar>
<Button tone="primary" onClick={() => setAmcDialog({})}>New AMC</Button>
</Toolbar>
)}
{amc.error !== undefined && <ErrorState message={amc.error} onRetry={amc.reload} />}
{amc.data === undefined && amc.error === undefined ? <Skeleton />
: (amc.data ?? []).length === 0 ? <EmptyState>No AMC contracts.</EmptyState> : (
@ -355,6 +382,7 @@ export function ClientDetail() {
paid: <Badge tone={AMC_TONE[a.paidStatus]}>{a.paidStatus}</Badge>,
act: (
<div style={{ display: 'flex', gap: 6 }}>
{isOwner && <Button onClick={() => setAmcDialog({ initial: a })}>Edit</Button>}
{a.paidStatus !== 'unpaid' && (
<Button tone="primary" onClick={() => {
setActionErr(undefined)
@ -363,25 +391,34 @@ export function ClientDetail() {
}}>Generate renewal invoice</Button>
)}
{a.active && (
<Button onClick={() => {
setActionErr(undefined)
deactivateAmc(a.id)
.then(() => amc.reload()).catch((err: Error) => setActionErr(err.message))
}}>Deactivate</Button>
<Button tone="danger" onClick={() => setConfirm({
title: 'Deactivate AMC contract',
body: `Deactivate the AMC contract${a.coverage !== '' ? ` "${a.coverage}"` : ''} (${a.periodFrom}${a.periodTo})? This does not affect existing invoices.`,
label: 'Deactivate',
tone: 'danger',
run: () => deactivateAmc(a.id).then(() => { toast.ok('AMC deactivated'); amc.reload() }),
})}>Deactivate</Button>
)}
</div>
),
}))}
/>
)}
<AmcDialog
open={amcDialog !== undefined}
onClose={() => setAmcDialog(undefined)}
clientId={id}
initial={amcDialog?.initial}
onDone={() => amc.reload()}
/>
</>
)}
{tab === 'interactions' && (
<>
{types.data !== undefined && (
<LogInteractionForm clientId={id} types={types.data} onDone={() => interactions.reload()} />
)}
<Toolbar>
<Button tone="primary" onClick={() => setInteractionDialog({})}>Log interaction</Button>
</Toolbar>
{interactions.error !== undefined && <ErrorState message={interactions.error} onRetry={interactions.reload} />}
{interactions.data === undefined && interactions.error === undefined ? <Skeleton />
: (interactions.data ?? []).length === 0 ? <EmptyState>No interactions logged yet.</EmptyState> : (
@ -389,7 +426,7 @@ export function ClientDetail() {
columns={[
{ key: 'on', label: 'Date' }, { key: 'type', label: 'Type' },
{ key: 'notes', label: 'Notes' }, { key: 'outcome', label: 'Outcome' },
{ key: 'follow', label: 'Follow-up' },
{ key: 'follow', label: 'Follow-up' }, { key: 'act', label: '' },
]}
rows={(interactions.data ?? []).map((i) => ({
on: i.onDate,
@ -407,9 +444,18 @@ export function ClientDetail() {
}}
/>
),
act: <Button onClick={() => setInteractionDialog({ initial: i })}>Edit</Button>,
}))}
/>
)}
<InteractionDialog
open={interactionDialog !== undefined}
onClose={() => setInteractionDialog(undefined)}
clientId={id}
types={types.data ?? []}
initial={interactionDialog?.initial}
onDone={() => interactions.reload()}
/>
</>
)}
@ -431,6 +477,18 @@ export function ClientDetail() {
)}
</>
)}
{confirm !== undefined && (
<ConfirmDialog
open
onClose={() => setConfirm(undefined)}
onConfirm={confirm.run}
title={confirm.title}
body={confirm.body}
confirmLabel={confirm.label}
{...(confirm.tone !== undefined ? { tone: confirm.tone } : {})}
/>
)}
</div>
)
}

@ -1,5 +1,8 @@
import { useState } from 'react'
import { Badge, Button, DataTable, EmptyState, ErrorState, Field, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui'
import { useEffect, useState } from 'react'
import {
Badge, Button, ConfirmDialog, DataTable, Dialog, EmptyState, ErrorState, FormField, FormGrid,
Notice, PageHeader, Skeleton, Toolbar, useToast,
} from '@sims/ui'
import {
createEmployee, deactivateEmployee, getEmployees, patchEmployee,
reactivateEmployee, resetEmployeePassword,
@ -7,22 +10,27 @@ import {
} from '../api'
import { useData } from './Clients'
type DialogState =
| { kind: 'add' }
| { kind: 'edit'; employee: Employee }
| { kind: 'reset'; employee: Employee }
/**
* Employee management (owner-only page; the nav item is hidden for others and the
* server 403s every mutation anyway). Standard table per spec §6a: name, email,
* role, active, actions plus add / edit / reset-password forms. Email is the
* role, active, actions plus add / edit / reset-password dialogs. Email is the
* login id and is immutable once created.
*/
export function Employees() {
const list = useData(getEmployees, [])
const [adding, setAdding] = useState(false)
const [editing, setEditing] = useState<Employee | undefined>()
const [resetting, setResetting] = useState<Employee | undefined>()
const toast = useToast()
const [dialog, setDialog] = useState<DialogState | undefined>()
const [confirm, setConfirm] = useState<{ employee: Employee } | undefined>()
const [error, setError] = useState<string | undefined>()
const act = (p: Promise<unknown>) => {
const act = (p: Promise<unknown>, ok: string) => {
setError(undefined)
p.then(() => list.reload()).catch((e: Error) => setError(e.message))
p.then(() => { toast.ok(ok); list.reload() }).catch((e: Error) => setError(e.message))
}
const employees = list.data?.employees
@ -32,33 +40,11 @@ export function Employees() {
title="Employees"
desc="Console users — owner manages accounts, roles and passwords. Deactivation locks the account out immediately."
actions={(
<Button tone="primary" onClick={() => { setAdding((v) => !v); setEditing(undefined); setResetting(undefined) }}>
<Button tone="primary" onClick={() => setDialog({ kind: 'add' })}>
+ Add employee
</Button>
)}
/>
{adding && (
<AddEmployeeForm
onDone={() => { setAdding(false); list.reload() }}
onCancel={() => setAdding(false)}
/>
)}
{editing !== undefined && (
<EditEmployeeForm
key={editing.id}
employee={editing}
onDone={() => { setEditing(undefined); list.reload() }}
onCancel={() => setEditing(undefined)}
/>
)}
{resetting !== undefined && (
<ResetPasswordForm
key={resetting.id}
employee={resetting}
onDone={() => { setResetting(undefined); list.reload() }}
onCancel={() => setResetting(undefined)}
/>
)}
{error !== undefined && <Notice tone="err">{error}</Notice>}
{/* Branch on the fetch error first `employees` stays undefined on failure,
so falling through would show the skeleton forever under the error state. */}
@ -78,11 +64,11 @@ export function Employees() {
active: e.active ? <Badge tone="ok">active</Badge> : <Badge tone="err">inactive</Badge>,
actions: (
<span style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Button onClick={() => { setEditing(e); setAdding(false); setResetting(undefined) }}>Edit</Button>
<Button onClick={() => { setResetting(e); setAdding(false); setEditing(undefined) }}>Reset pw</Button>
<Button onClick={() => setDialog({ kind: 'edit', employee: e })}>Edit</Button>
<Button onClick={() => setDialog({ kind: 'reset', employee: e })}>Reset pw</Button>
{e.active
? <Button tone="danger" onClick={() => act(deactivateEmployee(e.id))}>Deactivate</Button>
: <Button onClick={() => act(reactivateEmployee(e.id))}>Reactivate</Button>}
? <Button tone="danger" onClick={() => setConfirm({ employee: e })}>Deactivate</Button>
: <Button onClick={() => act(reactivateEmployee(e.id), 'Employee reactivated')}>Reactivate</Button>}
</span>
),
}))}
@ -95,99 +81,138 @@ export function Employees() {
</Toolbar>
</>
)}
</div>
)
}
function AddEmployeeForm(props: { onDone: () => void; onCancel: () => void }) {
const [f, setF] = useState({ email: '', displayName: '', role: 'staff' as EmployeeRole, password: '' })
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.email.trim() === '' || f.displayName.trim() === '') { setError('Email and name are required'); return }
if (f.password.length < 8) { setError('Initial password must be at least 8 characters'); return }
setError(undefined)
createEmployee({ email: f.email.trim(), displayName: f.displayName.trim(), role: f.role, password: f.password })
.then(props.onDone)
.catch((e: Error) => setError(e.message))
}
<EmployeeDialog state={dialog} onClose={() => setDialog(undefined)} onDone={() => list.reload()} />
return (
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Email (login id)"><input className="wf" type="email" autoFocus value={f.email} onChange={set('email')} /></Field>
<Field label="Name"><input className="wf" value={f.displayName} onChange={set('displayName')} /></Field>
<Field label="Role">
<select className="wf" value={f.role} onChange={set('role')}>
{EMPLOYEE_ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</Field>
<Field label="Initial password (min 8)"><input className="wf" type="password" value={f.password} onChange={set('password')} /></Field>
<Button tone="primary" onClick={save}>Create employee</Button>
<Button onClick={props.onCancel}>Cancel</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
{confirm !== undefined && (
<ConfirmDialog
open
onClose={() => setConfirm(undefined)}
onConfirm={() => deactivateEmployee(confirm.employee.id).then(() => { toast.ok('Employee deactivated'); list.reload() })}
title="Deactivate employee"
body={`Deactivate ${confirm.employee.displayName} (${confirm.employee.email})? Their account is locked out immediately.`}
confirmLabel="Deactivate"
tone="danger"
/>
)}
</div>
)
}
function EditEmployeeForm(props: { employee: Employee; onDone: () => void; onCancel: () => void }) {
const [f, setF] = useState({ displayName: props.employee.displayName, role: props.employee.role })
/**
* Owner-only: add / edit / reset-password one dialog, three modes, single open-slot
* (`dialog` in the parent). Field sets and validation copy are unchanged from the
* inline forms they replace only the chrome (Dialog + pill footer) moved.
*/
function EmployeeDialog(props: { state: DialogState | undefined; onClose: () => void; onDone: () => void }) {
const toast = useToast()
const open = props.state !== undefined
const kind = props.state?.kind
const employee = props.state?.kind === 'edit' || props.state?.kind === 'reset' ? props.state.employee : undefined
const [addF, setAddF] = useState({ email: '', displayName: '', role: 'staff' as EmployeeRole, password: '' })
const [editF, setEditF] = useState({ displayName: '', role: 'staff' as EmployeeRole })
const [password, setPassword] = useState('')
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
const save = () => {
if (f.displayName.trim() === '') { setError('Name is required'); return }
useEffect(() => {
if (!open) return
setError(undefined)
patchEmployee(props.employee.id, { displayName: f.displayName.trim(), role: f.role })
.then(props.onDone)
.catch((e: Error) => setError(e.message))
}
setAddF({ email: '', displayName: '', role: 'staff', password: '' })
setEditF({ displayName: employee?.displayName ?? '', role: employee?.role ?? 'staff' })
setPassword('')
}, [open, props.state])
return (
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
{/* Email is the login id — immutable after creation. */}
<Field label="Email (immutable)"><input className="wf" value={props.employee.email} disabled /></Field>
<Field label="Name">
<input className="wf" autoFocus value={f.displayName} onChange={(e) => setF((p) => ({ ...p, displayName: e.target.value }))} />
</Field>
<Field label="Role">
<select className="wf" value={f.role} onChange={(e) => setF((p) => ({ ...p, role: e.target.value as EmployeeRole }))}>
{EMPLOYEE_ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</Field>
<Button tone="primary" onClick={save}>Save changes</Button>
<Button onClick={props.onCancel}>Cancel</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}
function ResetPasswordForm(props: { employee: Employee; onDone: () => void; onCancel: () => void }) {
const [password, setPassword] = useState('')
const [error, setError] = useState<string | undefined>()
const close = () => { if (!saving) props.onClose() }
const save = () => {
if (saving) return
if (kind === 'add') {
if (addF.email.trim() === '' || addF.displayName.trim() === '') { setError('Email and name are required'); return }
if (addF.password.length < 8) { setError('Initial password must be at least 8 characters'); return }
setError(undefined)
setSaving(true)
createEmployee({ email: addF.email.trim(), displayName: addF.displayName.trim(), role: addF.role, password: addF.password })
.then(() => { toast.ok('Employee created'); props.onDone(); props.onClose() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
} else if (kind === 'edit' && employee !== undefined) {
if (editF.displayName.trim() === '') { setError('Name is required'); return }
setError(undefined)
setSaving(true)
patchEmployee(employee.id, { displayName: editF.displayName.trim(), role: editF.role })
.then(() => { toast.ok('Employee updated'); props.onDone(); props.onClose() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
} else if (kind === 'reset' && employee !== undefined) {
if (password.length < 8) { setError('New password must be at least 8 characters'); return }
setError(undefined)
resetEmployeePassword(props.employee.id, password)
.then(props.onDone)
setSaving(true)
resetEmployeePassword(employee.id, password)
.then(() => { toast.ok('Password reset'); props.onDone(); props.onClose() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
}
}
const title = kind === 'add' ? 'Add employee'
: kind === 'edit' ? `Edit ${employee?.displayName ?? ''}`
: kind === 'reset' ? `Reset password — ${employee?.email ?? ''}`
: ''
const saveLabel = kind === 'add' ? 'Create employee' : kind === 'reset' ? 'Reset password' : 'Save changes'
return (
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label={`New password for ${props.employee.email} (min 8)`}>
<Dialog
open={open}
onClose={close}
title={title}
footer={
<>
<Button pill onClick={close}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : saveLabel}</Button>
</>
}
>
{kind === 'add' && (
<FormGrid>
<FormField label="Email (login id)">
<input className="wf" type="email" autoFocus value={addF.email} onChange={(e) => setAddF((p) => ({ ...p, email: e.target.value }))} />
</FormField>
<FormField label="Name">
<input className="wf" value={addF.displayName} onChange={(e) => setAddF((p) => ({ ...p, displayName: e.target.value }))} />
</FormField>
<FormField label="Role">
<select className="wf" value={addF.role} onChange={(e) => setAddF((p) => ({ ...p, role: e.target.value as EmployeeRole }))}>
{EMPLOYEE_ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</FormField>
<FormField label="Initial password (min 8)">
<input className="wf" type="password" value={addF.password} onChange={(e) => setAddF((p) => ({ ...p, password: e.target.value }))} />
</FormField>
</FormGrid>
)}
{kind === 'edit' && (
<FormGrid>
<FormField label="Email (immutable)"><input className="wf" value={employee?.email ?? ''} disabled /></FormField>
<FormField label="Name">
<input className="wf" autoFocus value={editF.displayName} onChange={(e) => setEditF((p) => ({ ...p, displayName: e.target.value }))} />
</FormField>
<FormField label="Role">
<select className="wf" value={editF.role} onChange={(e) => setEditF((p) => ({ ...p, role: e.target.value as EmployeeRole }))}>
{EMPLOYEE_ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</FormField>
</FormGrid>
)}
{kind === 'reset' && (
<FormGrid>
<FormField label={`New password for ${employee?.email ?? ''} (min 8)`} wide>
<input className="wf" type="password" autoFocus value={password} onChange={(e) => setPassword(e.target.value)} />
</Field>
<Button tone="primary" onClick={save}>Reset password</Button>
<Button onClick={props.onCancel}>Cancel</Button>
</div>
</FormField>
</FormGrid>
)}
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
</Dialog>
)
}

@ -1,6 +1,9 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, ErrorState, Field, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui'
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,
@ -28,12 +31,9 @@ export function Modules() {
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>
? <Button tone="primary" onClick={() => setCreating(true)}>New module</Button>
: <Badge>read-only (staff)</Badge>}
/>
{creating && isOwner && (
<NewModuleForm onCreated={() => { setCreating(false); modules.reload() }} />
)}
{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> : (
@ -59,6 +59,13 @@ export function Modules() {
<PriceBook module={selected} isOwner={isOwner} />
</>
)}
{isOwner && (
<NewModuleDialog
open={creating}
onClose={() => setCreating(false)}
onCreated={() => { setCreating(false); modules.reload() }}
/>
)}
</div>
)
}
@ -157,6 +164,7 @@ function ModuleClients(props: { module: Module }) {
/** 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 [saved, setSaved] = useState(false)
const [error, setError] = useState<string | undefined>()
@ -166,7 +174,7 @@ function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () =>
const save = () => {
setError(undefined)
patchModule(props.module.id, { quoteContent: splitLines(text) })
.then(() => { setSaved(true); props.onSaved() })
.then(() => { setSaved(true); toast.ok('Quote content saved'); props.onSaved() })
.catch((e: Error) => setError(e.message))
}
@ -203,37 +211,63 @@ function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () =>
)
}
function NewModuleForm(props: { onCreated: () => void }) {
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(props.onCreated)
.then(() => { toast.ok('Module created'); props.onCreated() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
}
return (
<div className="wf-card">
<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 }}>
<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)} />
@ -241,71 +275,116 @@ function NewModuleForm(props: { onCreated: () => void }) {
</label>
))}
</div>
</Field>
</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>
<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>
</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>}
</div>
</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(() => { setF((p) => ({ ...p, priceRs: '' })); prices.reload() })
.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={
<>
<h3 style={{ marginTop: 20 }}>Prices {props.module.name}</h3>
{props.isOwner && (
<Toolbar>
<select className="wf" value={f.kind} onChange={set('kind')}>
<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>
<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>
)}
</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>}
{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 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,
}))}
/>
)}
</>
</Dialog>
)
}

@ -1,11 +1,12 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { fromRupees } from '@sims/domain'
import { Button, Field, Notice, Toolbar } from '@sims/ui'
import { Button, Dialog, Field, FormField, FormGrid, Notice, Toolbar, useToast } from '@sims/ui'
import {
createAmc, createInteraction, createRecurringPlan, patchClientModule, recordPayment,
updateAmc, updateInteraction, updateRecurringPlan,
CLIENT_MODULE_STATUSES, KIND_LABEL, PAYMENT_MODES,
type ClientModule, type ClientModuleStatus, type Employee, type InteractionType,
type Kind, type Module, type PaymentMode,
type AmcContract, type ClientModule, type ClientModuleStatus, type Employee, type Interaction,
type InteractionType, type Kind, type Module, type PaymentMode, type RecurringPlan,
} from '../api'
const today = () => new Date().toISOString().slice(0, 10)
@ -35,17 +36,33 @@ export function AccountOwnerSelect(props: {
)
}
/** Owner-only: create a recurring plan on one of the client's modules. */
export function NewRecurringForm(props: {
clientId: string; options: { id: string; label: string }[]; onDone: () => void
/** Owner-only: create or edit a recurring plan on one of the client's modules. */
export function PlanDialog(props: {
open: boolean; onClose: () => void; clientId: string
options: { id: string; label: string }[]; initial?: RecurringPlan; onDone: () => void
}) {
const toast = useToast()
const editing = props.initial !== undefined
const [f, setF] = useState({ clientModuleId: '', cadence: 'monthly', amountRs: '', nextRun: today(), policy: 'manual' })
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!props.open) return
const p = props.initial
setError(undefined)
setF({
clientModuleId: p?.clientModuleId ?? '', cadence: p?.cadence ?? 'monthly',
amountRs: p?.amountPaise !== null && p?.amountPaise !== undefined ? String(p.amountPaise / 100) : '',
nextRun: p?.nextRun ?? today(), policy: p?.policy ?? 'manual',
})
}, [props.open, props.initial])
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value }))
const save = () => {
if (saving) return
if (f.clientModuleId === '') { setError('Pick a subscribed module'); return }
const amount = f.amountRs === '' ? undefined : Number(f.amountRs)
if (amount !== undefined && (!Number.isFinite(amount) || amount <= 0)) {
@ -53,54 +70,82 @@ export function NewRecurringForm(props: {
}
setError(undefined)
setSaving(true)
createRecurringPlan(props.clientId, {
const body = {
clientModuleId: f.clientModuleId, cadence: f.cadence, nextRun: f.nextRun, policy: f.policy,
...(amount !== undefined ? { amountPaise: fromRupees(amount) } : {}),
})
.then(() => { setF({ clientModuleId: '', cadence: 'monthly', amountRs: '', nextRun: today(), policy: 'manual' }); props.onDone() })
}
const call = editing ? updateRecurringPlan(props.initial!.id, body) : createRecurringPlan(props.clientId, body)
call
.then(() => { toast.ok('Plan saved'); props.onDone(); props.onClose() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
}
return (
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Module">
<select className="wf" value={f.clientModuleId} onChange={set('clientModuleId')}>
<Dialog
open={props.open}
onClose={() => { if (!saving) props.onClose() }}
title={editing ? 'Edit recurring plan' : 'New recurring plan'}
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : editing ? 'Save changes' : 'New recurring plan'}</Button>
</>
}
>
<FormGrid>
<FormField label="Module">
<select className="wf" autoFocus value={f.clientModuleId} onChange={set('clientModuleId')}>
<option value="">Module</option>
{props.options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</Field>
<Field label="Cadence">
</FormField>
<FormField label="Cadence">
<select className="wf" value={f.cadence} onChange={set('cadence')}>
<option value="monthly">monthly</option>
<option value="yearly">yearly</option>
</select>
</Field>
<Field label="Amount (₹, optional)"><input className="wf num" style={{ width: 120 }} value={f.amountRs} onChange={set('amountRs')} /></Field>
<Field label="Next run"><input type="date" className="wf" value={f.nextRun} onChange={set('nextRun')} /></Field>
<Field label="Policy">
</FormField>
<FormField label="Amount (₹, optional)"><input className="wf num" value={f.amountRs} onChange={set('amountRs')} /></FormField>
<FormField label="Next run"><input type="date" className="wf" value={f.nextRun} onChange={set('nextRun')} /></FormField>
<FormField label="Policy">
<select className="wf" value={f.policy} onChange={set('policy')}>
<option value="manual">manual</option>
<option value="auto">auto</option>
</select>
</Field>
<Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'New recurring plan'}</Button>
</div>
</FormField>
</FormGrid>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
</Dialog>
)
}
/** Owner-only: create an AMC contract — amount entered in rupees, stored in paise. */
export function NewAmcForm(props: { clientId: string; onDone: () => void }) {
/** Owner-only: create or edit an AMC contract — amount entered in rupees, stored in paise. */
export function AmcDialog(props: {
open: boolean; onClose: () => void; clientId: string; initial?: AmcContract; onDone: () => void
}) {
const toast = useToast()
const editing = props.initial !== undefined
const [f, setF] = useState({ coverage: '', periodFrom: today(), periodTo: '', amountRs: '', reminderDays: '30' })
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!props.open) return
const a = props.initial
setError(undefined)
setF({
coverage: a?.coverage ?? '', periodFrom: a?.periodFrom ?? today(), periodTo: a?.periodTo ?? '',
amountRs: a !== undefined ? String(a.amountPaise / 100) : '',
reminderDays: a !== undefined ? String(a.renewalReminderDays) : '30',
})
}, [props.open, props.initial])
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value }))
const save = () => {
if (saving) return
const amount = Number(f.amountRs)
if (!Number.isFinite(amount) || amount <= 0) { setError('Enter the contract amount in rupees'); return }
if (f.periodTo === '') { setError('Pick the period end date'); return }
@ -108,78 +153,115 @@ export function NewAmcForm(props: { clientId: string; onDone: () => void }) {
if (!Number.isInteger(days) || days < 0) { setError('Reminder days must be a non-negative integer'); return }
setError(undefined)
setSaving(true)
createAmc(props.clientId, {
const body = {
coverage: f.coverage, periodFrom: f.periodFrom, periodTo: f.periodTo,
amountPaise: fromRupees(amount), renewalReminderDays: days,
})
.then(() => { setF({ coverage: '', periodFrom: today(), periodTo: '', amountRs: '', reminderDays: '30' }); props.onDone() })
}
const call = editing ? updateAmc(props.initial!.id, body) : createAmc(props.clientId, body)
call
.then(() => { toast.ok('AMC saved'); props.onDone(); props.onClose() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
}
return (
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Coverage"><input className="wf" value={f.coverage} onChange={set('coverage')} /></Field>
<Field label="Period from"><input type="date" className="wf" value={f.periodFrom} onChange={set('periodFrom')} /></Field>
<Field label="Period to"><input type="date" className="wf" value={f.periodTo} onChange={set('periodTo')} /></Field>
<Field label="Amount (₹)"><input className="wf num" style={{ width: 120 }} value={f.amountRs} onChange={set('amountRs')} /></Field>
<Field label="Reminder days"><input className="wf num" style={{ width: 80 }} value={f.reminderDays} onChange={set('reminderDays')} /></Field>
<Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'New AMC'}</Button>
</div>
<Dialog
open={props.open}
onClose={() => { if (!saving) props.onClose() }}
title={editing ? 'Edit AMC contract' : 'New AMC contract'}
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : editing ? 'Save changes' : 'New AMC'}</Button>
</>
}
>
<FormGrid>
<FormField label="Coverage" wide><input className="wf" autoFocus value={f.coverage} onChange={set('coverage')} /></FormField>
<FormField label="Period from"><input type="date" className="wf" value={f.periodFrom} onChange={set('periodFrom')} /></FormField>
<FormField label="Period to"><input type="date" className="wf" value={f.periodTo} onChange={set('periodTo')} /></FormField>
<FormField label="Amount (₹)"><input className="wf num" value={f.amountRs} onChange={set('amountRs')} /></FormField>
<FormField label="Reminder days"><input className="wf num" value={f.reminderDays} onChange={set('reminderDays')} /></FormField>
</FormGrid>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
</Dialog>
)
}
/** All roles: log a call / visit / training …; a follow-up date feeds the daily scan. */
export function LogInteractionForm(props: { clientId: string; types: InteractionType[]; onDone: () => void }) {
/** All roles: log or edit a call / visit / training …; a follow-up date feeds the daily scan. */
export function InteractionDialog(props: {
open: boolean; onClose: () => void; clientId: string
types: InteractionType[]; initial?: Interaction; onDone: () => void
}) {
const toast = useToast()
const editing = props.initial !== undefined
const [f, setF] = useState({ typeCode: '', onDate: today(), notes: '', outcome: '', followUpOn: '' })
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!props.open) return
const i = props.initial
setError(undefined)
setF({
typeCode: i?.typeCode ?? '', onDate: i?.onDate ?? today(), notes: i?.notes ?? '',
outcome: i?.outcome ?? '', followUpOn: i?.followUpOn ?? '',
})
}, [props.open, props.initial])
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value }))
const save = () => {
if (saving) return
if (f.typeCode === '') { setError('Pick an interaction type'); return }
setError(undefined)
setSaving(true)
createInteraction(props.clientId, {
const body = {
typeCode: f.typeCode, onDate: f.onDate, notes: f.notes,
...(f.outcome !== '' ? { outcome: f.outcome } : {}),
...(f.followUpOn !== '' ? { followUpOn: f.followUpOn } : {}),
})
.then(() => { setF({ typeCode: '', onDate: today(), notes: '', outcome: '', followUpOn: '' }); props.onDone() })
}
const call = editing ? updateInteraction(props.initial!.id, body) : createInteraction(props.clientId, body)
call
.then(() => { toast.ok('Interaction saved'); props.onDone(); props.onClose() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
}
return (
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Type">
<select className="wf" value={f.typeCode} onChange={set('typeCode')}>
<Dialog
open={props.open}
onClose={() => { if (!saving) props.onClose() }}
title={editing ? 'Edit interaction' : 'Log interaction'}
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? (editing ? 'Saving…' : 'Logging…') : editing ? 'Save changes' : 'Log interaction'}</Button>
</>
}
>
<FormGrid>
<FormField label="Type">
<select className="wf" autoFocus value={f.typeCode} onChange={set('typeCode')}>
<option value="">Type</option>
{props.types.map((t) => <option key={t.code} value={t.code}>{t.label}</option>)}
</select>
</Field>
<Field label="On"><input type="date" className="wf" value={f.onDate} onChange={set('onDate')} /></Field>
<Field label="Outcome">
</FormField>
<FormField label="On"><input type="date" className="wf" value={f.onDate} onChange={set('onDate')} /></FormField>
<FormField label="Outcome">
<select className="wf" value={f.outcome} onChange={set('outcome')}>
<option value=""></option>
<option value="positive">positive</option>
<option value="neutral">neutral</option>
<option value="negative">negative</option>
</select>
</Field>
<Field label="Follow-up on"><input type="date" className="wf" value={f.followUpOn} onChange={set('followUpOn')} /></Field>
<Button tone="primary" onClick={save}>{saving ? 'Logging…' : 'Log interaction'}</Button>
</div>
<div style={{ marginTop: 8 }}>
<Field label="Notes"><textarea className="wf" rows={2} style={{ width: '100%' }} value={f.notes} onChange={set('notes')} /></Field>
</div>
</FormField>
<FormField label="Follow-up on"><input type="date" className="wf" value={f.followUpOn} onChange={set('followUpOn')} /></FormField>
<FormField label="Notes" wide><textarea className="wf" rows={2} value={f.notes} onChange={set('notes')} /></FormField>
</FormGrid>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
</Dialog>
)
}

Loading…
Cancel
Save