From 1a8a3faf5d478ad956446cd1ffe70012c075f7f1 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 13:45:46 +0530 Subject: [PATCH] =?UTF-8?q?feat(hq-web):=20AMC/plan/interaction/employee/m?= =?UTF-8?q?odule=20dialogs=20=E2=80=94=20edit=20everywhere,=20confirmed=20?= =?UTF-8?q?deactivations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/pages/ClientDetail.tsx | 116 +++++++++--- apps/hq-web/src/pages/Employees.tsx | 251 ++++++++++++++----------- apps/hq-web/src/pages/Modules.tsx | 187 ++++++++++++------ apps/hq-web/src/pages/client-forms.tsx | 200 ++++++++++++++------ 4 files changed, 499 insertions(+), 255 deletions(-) diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index c8d10c5..069d726 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -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 = { 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 } + /** 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() 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() 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() { )}

Recurring plans

{isOwner && cms.data !== undefined && ( - cm.active).map((cm) => ({ - id: cm.id, label: `${moduleName(cm.moduleId)} · ${KIND_LABEL[cm.kind]}`, - }))} - onDone={() => plans.reload()} - /> + + + )} {plans.error !== undefined && } {plans.data === undefined && plans.error === undefined ? @@ -320,23 +326,44 @@ export function ClientDetail() { next: plan.nextRun, policy: {plan.policy}, active: plan.active ? 'yes' : 'no', - act: plan.active ? ( - - ) : '—', + act: ( +
+ {isOwner && } + {plan.active && ( + + )} +
+ ), } })} /> )} + 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 && amc.reload()} />} + {isOwner && ( + + + + )} {amc.error !== undefined && } {amc.data === undefined && amc.error === undefined ? : (amc.data ?? []).length === 0 ? No AMC contracts. : ( @@ -355,6 +382,7 @@ export function ClientDetail() { paid: {a.paidStatus}, act: (
+ {isOwner && } {a.paidStatus !== 'unpaid' && ( )} {a.active && ( - + )}
), }))} /> )} + setAmcDialog(undefined)} + clientId={id} + initial={amcDialog?.initial} + onDone={() => amc.reload()} + /> )} {tab === 'interactions' && ( <> - {types.data !== undefined && ( - interactions.reload()} /> - )} + + + {interactions.error !== undefined && } {interactions.data === undefined && interactions.error === undefined ? : (interactions.data ?? []).length === 0 ? No interactions logged yet. : ( @@ -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: , }))} /> )} + setInteractionDialog(undefined)} + clientId={id} + types={types.data ?? []} + initial={interactionDialog?.initial} + onDone={() => interactions.reload()} + /> )} @@ -431,6 +477,18 @@ export function ClientDetail() { )} )} + + {confirm !== undefined && ( + setConfirm(undefined)} + onConfirm={confirm.run} + title={confirm.title} + body={confirm.body} + confirmLabel={confirm.label} + {...(confirm.tone !== undefined ? { tone: confirm.tone } : {})} + /> + )} ) } diff --git a/apps/hq-web/src/pages/Employees.tsx b/apps/hq-web/src/pages/Employees.tsx index fcbacf6..938681f 100644 --- a/apps/hq-web/src/pages/Employees.tsx +++ b/apps/hq-web/src/pages/Employees.tsx @@ -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() - const [resetting, setResetting] = useState() + const toast = useToast() + const [dialog, setDialog] = useState() + const [confirm, setConfirm] = useState<{ employee: Employee } | undefined>() const [error, setError] = useState() - const act = (p: Promise) => { + const act = (p: Promise, 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={( - )} /> - {adding && ( - { setAdding(false); list.reload() }} - onCancel={() => setAdding(false)} - /> - )} - {editing !== undefined && ( - { setEditing(undefined); list.reload() }} - onCancel={() => setEditing(undefined)} - /> - )} - {resetting !== undefined && ( - { setResetting(undefined); list.reload() }} - onCancel={() => setResetting(undefined)} - /> - )} {error !== undefined && {error}} {/* 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 ? active : inactive, actions: ( - - + + {e.active - ? - : } + ? + : } ), }))} @@ -95,99 +81,138 @@ export function Employees() { )} - - ) -} -function AddEmployeeForm(props: { onDone: () => void; onCancel: () => void }) { - const [f, setF] = useState({ email: '', displayName: '', role: 'staff' as EmployeeRole, password: '' }) - const [error, setError] = useState() - 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)) - } + setDialog(undefined)} onDone={() => list.reload()} /> - return ( -
-
- - - - - - - - -
- {error !== undefined && {error}} + {confirm !== undefined && ( + 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" + /> + )}
) } -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() + 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 ( -
-
- {/* Email is the login id — immutable after creation. */} - - - setF((p) => ({ ...p, displayName: e.target.value }))} /> - - - - - - -
- {error !== undefined && {error}} -
- ) -} - -function ResetPasswordForm(props: { employee: Employee; onDone: () => void; onCancel: () => void }) { - const [password, setPassword] = useState('') - const [error, setError] = useState() + const close = () => { if (!saving) props.onClose() } const save = () => { - if (password.length < 8) { setError('New password must be at least 8 characters'); return } - setError(undefined) - resetEmployeePassword(props.employee.id, password) - .then(props.onDone) - .catch((e: Error) => setError(e.message)) + 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) + 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 ( -
-
- - setPassword(e.target.value)} /> - - - -
+ + + + + } + > + {kind === 'add' && ( + + + setAddF((p) => ({ ...p, email: e.target.value }))} /> + + + setAddF((p) => ({ ...p, displayName: e.target.value }))} /> + + + + + + setAddF((p) => ({ ...p, password: e.target.value }))} /> + + + )} + {kind === 'edit' && ( + + + + setEditF((p) => ({ ...p, displayName: e.target.value }))} /> + + + + + + )} + {kind === 'reset' && ( + + + setPassword(e.target.value)} /> + + + )} {error !== undefined && {error}} -
+ ) } diff --git a/apps/hq-web/src/pages/Modules.tsx b/apps/hq-web/src/pages/Modules.tsx index f07e399..dd2b1a8 100644 --- a/apps/hq-web/src/pages/Modules.tsx +++ b/apps/hq-web/src/pages/Modules.tsx @@ -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 - ? + ? : read-only (staff)} /> - {creating && isOwner && ( - { setCreating(false); modules.reload() }} /> - )} {modules.error !== undefined ? : modules.data === undefined ? : modules.data.length === 0 ? No modules yet. : ( @@ -59,6 +59,13 @@ export function Modules() { )} + {isOwner && ( + setCreating(false)} + onCreated={() => { setCreating(false); modules.reload() }} + /> + )} ) } @@ -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() @@ -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([...ALL_KINDS]) const [multi, setMulti] = useState(false) const [quoteText, setQuoteText] = useState('') const [error, setError] = useState() + 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 ( -
-
- - - - -
+ { if (!saving) props.onClose() }} + title="New module" + footer={ + <> + + + + } + > + + + + + +
{ALL_KINDS.map((k) => ( ))}
- - - -