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

@ -1,5 +1,8 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Badge, Button, DataTable, EmptyState, ErrorState, Field, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui' import {
Badge, Button, ConfirmDialog, DataTable, Dialog, EmptyState, ErrorState, FormField, FormGrid,
Notice, PageHeader, Skeleton, Toolbar, useToast,
} from '@sims/ui'
import { import {
createEmployee, deactivateEmployee, getEmployees, patchEmployee, createEmployee, deactivateEmployee, getEmployees, patchEmployee,
reactivateEmployee, resetEmployeePassword, reactivateEmployee, resetEmployeePassword,
@ -7,22 +10,27 @@ import {
} from '../api' } from '../api'
import { useData } from './Clients' 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 * 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, * 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. * login id and is immutable once created.
*/ */
export function Employees() { export function Employees() {
const list = useData(getEmployees, []) const list = useData(getEmployees, [])
const [adding, setAdding] = useState(false) const toast = useToast()
const [editing, setEditing] = useState<Employee | undefined>() const [dialog, setDialog] = useState<DialogState | undefined>()
const [resetting, setResetting] = useState<Employee | undefined>() const [confirm, setConfirm] = useState<{ employee: Employee } | undefined>()
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const act = (p: Promise<unknown>) => { const act = (p: Promise<unknown>, ok: string) => {
setError(undefined) 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 const employees = list.data?.employees
@ -32,33 +40,11 @@ export function Employees() {
title="Employees" title="Employees"
desc="Console users — owner manages accounts, roles and passwords. Deactivation locks the account out immediately." desc="Console users — owner manages accounts, roles and passwords. Deactivation locks the account out immediately."
actions={( actions={(
<Button tone="primary" onClick={() => { setAdding((v) => !v); setEditing(undefined); setResetting(undefined) }}> <Button tone="primary" onClick={() => setDialog({ kind: 'add' })}>
+ Add employee + Add employee
</Button> </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>} {error !== undefined && <Notice tone="err">{error}</Notice>}
{/* Branch on the fetch error first `employees` stays undefined on failure, {/* Branch on the fetch error first `employees` stays undefined on failure,
so falling through would show the skeleton forever under the error state. */} 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>, active: e.active ? <Badge tone="ok">active</Badge> : <Badge tone="err">inactive</Badge>,
actions: ( actions: (
<span style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <span style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Button onClick={() => { setEditing(e); setAdding(false); setResetting(undefined) }}>Edit</Button> <Button onClick={() => setDialog({ kind: 'edit', employee: e })}>Edit</Button>
<Button onClick={() => { setResetting(e); setAdding(false); setEditing(undefined) }}>Reset pw</Button> <Button onClick={() => setDialog({ kind: 'reset', employee: e })}>Reset pw</Button>
{e.active {e.active
? <Button tone="danger" onClick={() => act(deactivateEmployee(e.id))}>Deactivate</Button> ? <Button tone="danger" onClick={() => setConfirm({ employee: e })}>Deactivate</Button>
: <Button onClick={() => act(reactivateEmployee(e.id))}>Reactivate</Button>} : <Button onClick={() => act(reactivateEmployee(e.id), 'Employee reactivated')}>Reactivate</Button>}
</span> </span>
), ),
}))} }))}
@ -95,99 +81,138 @@ export function Employees() {
</Toolbar> </Toolbar>
</> </>
)} )}
</div>
)
}
function AddEmployeeForm(props: { onDone: () => void; onCancel: () => void }) { <EmployeeDialog state={dialog} onClose={() => setDialog(undefined)} onDone={() => list.reload()} />
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))
}
return ( {confirm !== undefined && (
<div className="wf-card"> <ConfirmDialog
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}> open
<Field label="Email (login id)"><input className="wf" type="email" autoFocus value={f.email} onChange={set('email')} /></Field> onClose={() => setConfirm(undefined)}
<Field label="Name"><input className="wf" value={f.displayName} onChange={set('displayName')} /></Field> onConfirm={() => deactivateEmployee(confirm.employee.id).then(() => { toast.ok('Employee deactivated'); list.reload() })}
<Field label="Role"> title="Deactivate employee"
<select className="wf" value={f.role} onChange={set('role')}> body={`Deactivate ${confirm.employee.displayName} (${confirm.employee.email})? Their account is locked out immediately.`}
{EMPLOYEE_ROLES.map((r) => <option key={r} value={r}>{r}</option>)} confirmLabel="Deactivate"
</select> tone="danger"
</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>}
</div> </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 [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
const save = () => { useEffect(() => {
if (f.displayName.trim() === '') { setError('Name is required'); return } if (!open) return
setError(undefined) setError(undefined)
patchEmployee(props.employee.id, { displayName: f.displayName.trim(), role: f.role }) setAddF({ email: '', displayName: '', role: 'staff', password: '' })
.then(props.onDone) setEditF({ displayName: employee?.displayName ?? '', role: employee?.role ?? 'staff' })
.catch((e: Error) => setError(e.message)) setPassword('')
} }, [open, props.state])
return ( const close = () => { if (!saving) props.onClose() }
<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 save = () => { 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 } if (password.length < 8) { setError('New password must be at least 8 characters'); return }
setError(undefined) setError(undefined)
resetEmployeePassword(props.employee.id, password) setSaving(true)
.then(props.onDone) resetEmployeePassword(employee.id, password)
.then(() => { toast.ok('Password reset'); props.onDone(); props.onClose() })
.catch((e: Error) => setError(e.message)) .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 ( return (
<div className="wf-card"> <Dialog
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}> open={open}
<Field label={`New password for ${props.employee.email} (min 8)`}> 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)} /> <input className="wf" type="password" autoFocus value={password} onChange={(e) => setPassword(e.target.value)} />
</Field> </FormField>
<Button tone="primary" onClick={save}>Reset password</Button> </FormGrid>
<Button onClick={props.onCancel}>Cancel</Button> )}
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>} {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 { 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 { import {
addPrice, createModule, downloadModuleClientsCsv, getModuleClients, getModules, getPrices, addPrice, createModule, downloadModuleClientsCsv, getModuleClients, getModules, getPrices,
notifyModuleClients, patchModule, previewSample, role, notifyModuleClients, patchModule, previewSample, role,
@ -28,12 +31,9 @@ export function Modules() {
title="Modules" title="Modules"
desc="What we sell — each with a dated price book (later rows win from their effective date)." desc="What we sell — each with a dated price book (later rows win from their effective date)."
actions={isOwner 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>} : <Badge>read-only (staff)</Badge>}
/> />
{creating && isOwner && (
<NewModuleForm onCreated={() => { setCreating(false); modules.reload() }} />
)}
{modules.error !== undefined ? <ErrorState message={modules.error} onRetry={modules.reload} /> {modules.error !== undefined ? <ErrorState message={modules.error} onRetry={modules.reload} />
: modules.data === undefined ? <Skeleton rows={5} /> : modules.data === undefined ? <Skeleton rows={5} />
: modules.data.length === 0 ? <EmptyState>No modules yet.</EmptyState> : ( : modules.data.length === 0 ? <EmptyState>No modules yet.</EmptyState> : (
@ -59,6 +59,13 @@ export function Modules() {
<PriceBook module={selected} isOwner={isOwner} /> <PriceBook module={selected} isOwner={isOwner} />
</> </>
)} )}
{isOwner && (
<NewModuleDialog
open={creating}
onClose={() => setCreating(false)}
onCreated={() => { setCreating(false); modules.reload() }}
/>
)}
</div> </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. */ /** Per-module "what's included" lines — prefilled into the composer, printed under the quote line. */
function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () => void }) { function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () => void }) {
const toast = useToast()
const [text, setText] = useState(props.module.quoteContent.join('\n')) const [text, setText] = useState(props.module.quoteContent.join('\n'))
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
@ -166,7 +174,7 @@ function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () =>
const save = () => { const save = () => {
setError(undefined) setError(undefined)
patchModule(props.module.id, { quoteContent: splitLines(text) }) 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)) .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 [f, setF] = useState({ code: '', name: '', sac: '998313' })
const [kinds, setKinds] = useState<Kind[]>([...ALL_KINDS]) const [kinds, setKinds] = useState<Kind[]>([...ALL_KINDS])
const [multi, setMulti] = useState(false) const [multi, setMulti] = useState(false)
const [quoteText, setQuoteText] = useState('') const [quoteText, setQuoteText] = useState('')
const [error, setError] = useState<string | undefined>() 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 } }) => const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value })) setF((p) => ({ ...p, [k]: e.target.value }))
const toggleKind = (k: Kind) => const toggleKind = (k: Kind) =>
setKinds((prev) => (prev.includes(k) ? prev.filter((x) => x !== k) : [...prev, k])) setKinds((prev) => (prev.includes(k) ? prev.filter((x) => x !== k) : [...prev, k]))
const save = () => { const save = () => {
if (saving) return
if (f.code.trim() === '' || f.name.trim() === '') { setError('Code and name are required'); 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 } if (kinds.length === 0) { setError('Pick at least one billing kind'); return }
setError(undefined)
setSaving(true)
createModule({ createModule({
code: f.code.trim().toUpperCase(), name: f.name.trim(), sac: f.sac.trim(), code: f.code.trim().toUpperCase(), name: f.name.trim(), sac: f.sac.trim(),
allowedKinds: ALL_KINDS.filter((k) => kinds.includes(k)), multiSubscription: multi, allowedKinds: ALL_KINDS.filter((k) => kinds.includes(k)), multiSubscription: multi,
quoteContent: splitLines(quoteText), quoteContent: splitLines(quoteText),
}) })
.then(props.onCreated) .then(() => { toast.ok('Module created'); props.onCreated() })
.catch((e: Error) => setError(e.message)) .catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
} }
return ( return (
<div className="wf-card"> <Dialog
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}> open={props.open}
<Field label="Code"><input className="wf" style={{ width: 100 }} value={f.code} autoFocus onChange={set('code')} /></Field> onClose={() => { if (!saving) props.onClose() }}
<Field label="Name"><input className="wf" value={f.name} onChange={set('name')} /></Field> title="New module"
<Field label="SAC"><input className="wf" style={{ width: 100 }} value={f.sac} onChange={set('sac')} /></Field> footer={
<Field label="Billing kinds"> <>
<div style={{ display: 'flex', gap: 10 }}> <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) => ( {ALL_KINDS.map((k) => (
<label key={k} style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}> <label key={k} style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" checked={kinds.includes(k)} onChange={() => toggleKind(k)} /> <input type="checkbox" checked={kinds.includes(k)} onChange={() => toggleKind(k)} />
@ -241,71 +275,116 @@ function NewModuleForm(props: { onCreated: () => void }) {
</label> </label>
))} ))}
</div> </div>
</Field> </FormField>
<FormField label="Multi-subscription">
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}> <label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" checked={multi} onChange={() => setMulti((v) => !v)} /> <input type="checkbox" checked={multi} onChange={() => setMulti((v) => !v)} />
multi-subscription multi-subscription
</label> </label>
<Field label="Quote content (one per line, optional)"> </FormField>
<textarea className="wf" rows={2} style={{ minWidth: 220 }} value={quoteText} onChange={(e) => setQuoteText(e.target.value)} /> <FormField label="Quote content (one per line, optional)" wide>
</Field> <textarea className="wf" rows={3} value={quoteText} onChange={(e) => setQuoteText(e.target.value)} />
<Button tone="primary" onClick={save}>Save module</Button> </FormField>
</div> </FormGrid>
{error !== undefined && <Notice tone="err">{error}</Notice>} {error !== undefined && <Notice tone="err">{error}</Notice>}
</div> </Dialog>
) )
} }
function PriceBook(props: { module: Module; isOwner: boolean }) { function PriceBook(props: { module: Module; isOwner: boolean }) {
const prices = useData(() => getPrices(props.module.id), [props.module.id]) 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 [f, setF] = useState({ kind: '' as Kind | '', edition: 'standard', priceRs: '', effectiveFrom: today() })
const [error, setError] = useState<string | undefined>() 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 } }) => const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value })) setF((p) => ({ ...p, [k]: e.target.value }))
const save = () => { const save = () => {
if (saving) return
const rs = Number(f.priceRs) const rs = Number(f.priceRs)
if (f.kind === '') { setError('Pick a kind'); return } if (f.kind === '') { setError('Pick a kind'); return }
if (!Number.isFinite(rs) || rs < 0) { setError('Enter the price in rupees'); return } if (!Number.isFinite(rs) || rs < 0) { setError('Enter the price in rupees'); return }
setError(undefined) setError(undefined)
setSaving(true)
addPrice(props.module.id, { addPrice(props.module.id, {
kind: f.kind, edition: f.edition.trim() !== '' ? f.edition.trim() : 'standard', kind: f.kind, edition: f.edition.trim() !== '' ? f.edition.trim() : 'standard',
pricePaise: fromRupees(rs), effectiveFrom: f.effectiveFrom, 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)) .catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
} }
return ( 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> <Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
{props.isOwner && ( <Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : 'Add price'}</Button>
<Toolbar> </>
<select className="wf" value={f.kind} onChange={set('kind')}> }
>
<FormGrid>
<FormField label="Kind">
<select className="wf" autoFocus value={f.kind} onChange={set('kind')}>
<option value="">Kind</option> <option value="">Kind</option>
{props.module.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)} {props.module.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select> </select>
<input className="wf" style={{ width: 110 }} placeholder="edition" value={f.edition} onChange={set('edition')} /> </FormField>
<input className="wf num" style={{ width: 110 }} placeholder="₹" value={f.priceRs} onChange={set('priceRs')} /> <FormField label="Edition"><input className="wf" value={f.edition} onChange={set('edition')} /></FormField>
<input type="date" className="wf" value={f.effectiveFrom} onChange={set('effectiveFrom')} /> <FormField label="Price (₹)"><input className="wf num" value={f.priceRs} onChange={set('priceRs')} /></FormField>
<Button tone="primary" onClick={save}>Add price</Button> <FormField label="Effective from"><input type="date" className="wf" value={f.effectiveFrom} onChange={set('effectiveFrom')} /></FormField>
</Toolbar> </FormGrid>
)}
{error !== undefined && <Notice tone="err">{error}</Notice>} {error !== undefined && <Notice tone="err">{error}</Notice>}
{prices.error !== undefined ? <ErrorState message={prices.error} onRetry={prices.reload} /> </Dialog>
: 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,
}))}
/>
)}
</>
) )
} }

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

Loading…
Cancel
Save