feat(d20): P3 — Tickets page, Client 360 tickets/branches, module service cards

Tickets: WORK page with whole-desk status chips + counts, Mine filter,
search, honest pager, per-row assign + status quick actions, Drop behind
confirm, New-ticket dialog (client type-ahead, branch select, module/kind
datalists). Client 360: Tickets tab (client-scoped, same actions) +
Branches section (add/rename/deactivate) + per-module ServiceDataCard
(provider/username, portal password ••••/Reveal/Copy managerial-only,
editable labeled details, remark). Also: /Apex/ git-ignored — real export
holds plaintext credentials, must never land in git.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent c58f0fdfc5
commit 1d598e8a96

3
.gitignore vendored

@ -19,3 +19,6 @@ ruvector.db
# Local demo dataset for screenshots (not shipped)
.demo-data/
.superpowers/
# Real APEX data exports (contain plaintext credentials) - never commit
/Apex/

@ -116,11 +116,29 @@ export const CLIENT_MODULE_STATUSES: ClientModuleStatus[] = [
'quoted', 'ordered', 'installing', 'installed', 'trained', 'live', 'expired', 'cancelled',
]
/** One labeled service-data row on a client module (free vocabulary, D20). */
export interface ServiceDetail { label: string; value: string }
export interface ClientModule {
id: string; clientId: string; moduleId: string; status: ClientModuleStatus
kind: Kind; edition: string
installedOn: string | null; completedOn: string | null; trainedOn: string | null
nextRenewal: string | null; active: boolean
/** D20 per-service operational data; the portal password itself is reveal-only. */
provider: string | null; username: string | null
details: ServiceDetail[]; remark: string | null
hasPassword: boolean
}
export interface ClientModulePatch {
status?: ClientModuleStatus; kind?: Kind; edition?: string
installedOn?: string | null; completedOn?: string | null; trainedOn?: string | null
nextRenewal?: string | null; active?: boolean
/** D20 service data ('' clears the text fields). */
provider?: string; username?: string; remark?: string
details?: ServiceDetail[]
/** Owner/manager only (server 403s otherwise) — encrypted at rest, reveals audited. */
password?: string
}
export type DocType = 'QUOTATION' | 'PROFORMA' | 'INVOICE' | 'RECEIPT' | 'CREDIT_NOTE'
@ -217,9 +235,75 @@ export const getClientModules = (clientId: string): Promise<ClientModule[]> =>
export const assignClientModule = (clientId: string, body: Record<string, unknown>): Promise<ClientModule> =>
apiFetch<{ clientModule: ClientModule }>(`/clients/${clientId}/modules`, { method: 'POST', body: JSON.stringify(body) })
.then((r) => r.clientModule)
export const patchClientModule = (id: string, body: Record<string, unknown>): Promise<ClientModule> =>
export const patchClientModule = (id: string, body: ClientModulePatch): Promise<ClientModule> =>
apiFetch<{ clientModule: ClientModule }>(`/client-modules/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
.then((r) => r.clientModule)
/** Decrypt-and-return the module's portal password — owner/manager; every call is audited. */
export const revealModulePassword = (id: string): Promise<string> =>
apiFetch<{ password: string }>(`/client-modules/${id}/reveal-password`, { method: 'POST', body: '{}' })
.then((r) => r.password)
// ---------- ticket desk + client branches (D20 APEX parity) ----------
export type TicketStatus = 'open' | 'in_progress' | 'waiting' | 'closed' | 'dropped'
export const TICKET_STATUSES: TicketStatus[] = ['open', 'in_progress', 'waiting', 'closed', 'dropped']
export interface Ticket {
id: string; clientId: string; clientName: string
branchId: string | null; branchName: string | null
moduleCode: string | null; kind: string; description: string
status: TicketStatus
assignedTo: string | null; assignedName: string | null
onlineOffline: string | null
openedOn: string; closedOn: string | null
createdBy: string; createdAt: string; source: string
}
export interface TicketsPage {
tickets: Ticket[]; total: number; page: number; pageSize: number
/** Whole-desk per-status counts (chips) + every kind seen so far (datalist suggestions). */
counts: Record<string, number>; kinds: string[]
}
/** Server-paginated with an honest total (rule 8); mine=true narrows to my assignments. */
export const getTickets = (
opts: {
status?: TicketStatus; mine?: boolean; clientId?: string; module?: string
q?: string; page?: number; pageSize?: number
} = {},
): Promise<TicketsPage> => {
const p = new URLSearchParams()
if (opts.status !== undefined) p.set('status', opts.status)
if (opts.mine === true) p.set('mine', '1')
if (opts.clientId !== undefined && opts.clientId !== '') p.set('clientId', opts.clientId)
if (opts.module !== undefined && opts.module !== '') p.set('module', opts.module)
if (opts.q !== undefined && opts.q !== '') p.set('q', opts.q)
if (opts.page !== undefined) p.set('page', String(opts.page))
if (opts.pageSize !== undefined) p.set('pageSize', String(opts.pageSize))
const q = p.toString()
return apiFetch<TicketsPage>(`/tickets${q === '' ? '' : `?${q}`}`)
}
export const createTicket = (body: {
clientId: string; branchId?: string; moduleCode?: string; kind?: string
description?: string; onlineOffline?: string; assignedTo?: string
}): Promise<Ticket> =>
apiFetch<{ ticket: Ticket }>('/tickets', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.ticket)
/** assignedTo: null clears the assignment; closing stamps closedOn server-side. */
export const patchTicket = (id: string, body: {
status?: TicketStatus; assignedTo?: string | null; kind?: string; description?: string
moduleCode?: string | null; branchId?: string | null; onlineOffline?: string | null
}): Promise<Ticket> =>
apiFetch<{ ticket: Ticket }>(`/tickets/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.ticket)
export interface Branch { id: string; clientId: string; name: string; code: string | null; active: boolean }
export const getBranches = (clientId: string): Promise<Branch[]> =>
apiFetch<{ branches: Branch[] }>(`/clients/${clientId}/branches`).then((r) => r.branches)
export const createBranch = (clientId: string, body: { name: string; code?: string }): Promise<Branch> =>
apiFetch<{ branch: Branch }>(`/clients/${clientId}/branches`, { method: 'POST', body: JSON.stringify(body) })
.then((r) => r.branch)
export const patchBranch = (id: string, body: { name?: string; code?: string; active?: boolean }): Promise<Branch> =>
apiFetch<{ branch: Branch }>(`/branches/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.branch)
export const getBillingSettings = (): Promise<{ paymentTermsDays: number }> =>
apiFetch<{ paymentTermsDays: number }>('/settings/billing')

@ -21,6 +21,7 @@ import { ImportApex } from './pages/ImportApex'
import { Pipeline } from './pages/Pipeline'
import { Documents } from './pages/Documents'
import { Reminders } from './pages/Reminders'
import { Tickets } from './pages/Tickets'
function App() {
return (
@ -31,6 +32,7 @@ function App() {
<Route path="/" element={<Dashboard />} />
<Route path="/pipeline" element={<Pipeline />} />
<Route path="/reminders" element={<Reminders />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} />
<Route path="/modules" element={<Modules />} />

@ -1,6 +1,6 @@
import {
BarChart3, BellRing, Boxes, FilePlus2, Files, Filter, LayoutDashboard, Settings as SettingsIcon,
Upload, UserCog, Users,
Upload, UserCog, Users, Wrench,
type LucideIcon,
} from 'lucide-react'
@ -19,6 +19,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/pipeline', label: 'Pipeline', icon: Filter },
{ to: '/reminders', label: 'Reminders', icon: BellRing },
{ to: '/tickets', label: 'Tickets', icon: Wrench },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/documents', label: 'Documents', icon: Files, end: true },
{ to: '/documents/new', label: 'New Document', icon: FilePlus2 },

@ -1,22 +1,27 @@
import { useState } from 'react'
import { Fragment, useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, RecordHeader, Skeleton,
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, Field, RecordHeader, Skeleton,
StatCard, Stats, Tabs, Toolbar, useToast,
} from '@sims/ui'
import {
assignClientModule, getClient, getClientModules, getLedger, getModules,
patchClient, revealDbPassword, role, isManagerial, getEmployees, setClientOwner,
patchClient, patchClientModule, revealDbPassword, revealModulePassword,
role, isManagerial, getEmployees, setClientOwner,
getRecurringPlans, deactivateRecurringPlan,
getAmc, deactivateAmc, generateAmcRenewalInvoice,
getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage,
getBranches, createBranch, patchBranch, getTickets,
CLIENT_STATUSES, KIND_LABEL,
type AmcContract, type AmcPaidStatus, type Client, type ClientStatus, type Interaction,
type Outcome, type RecurringPlan,
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule,
type ClientStatus, type Interaction, type Outcome, type RecurringPlan, type ServiceDetail,
} from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
import {
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE,
} from './Tickets'
import {
AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect,
InteractionDialog, PaymentForm, PlanDialog, RowDate,
@ -30,7 +35,7 @@ const inr = (p: number) => formatINR(p, { symbol: false })
const AMC_TONE: Record<AmcPaidStatus, 'ok' | 'warn' | 'err'> = { paid: 'ok', unpaid: 'err', unbilled: 'warn' }
const OUTCOME_TONE: Record<Outcome, 'ok' | 'warn' | 'err'> = { positive: 'ok', neutral: 'warn', negative: 'err' }
const TAB_KEYS = ['overview', 'modules', 'documents', 'payments', 'amc', 'interactions', 'aws'] as const
const TAB_KEYS = ['overview', 'modules', 'tickets', '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
@ -52,6 +57,10 @@ export function ClientDetail() {
const types = useData(getInteractionTypes, [])
const awsUsage = useData(() => getClientAwsUsage(id), [id])
const employees = useData(() => getEmployees(), [])
const branches = useData(() => getBranches(id), [id])
const [ticketPage, setTicketPage] = useState(1)
const tickets = useData(() => getTickets({ clientId: id, page: ticketPage, pageSize: 50 }), [id, ticketPage])
const [ticketDialog, setTicketDialog] = useState(false)
const isOwner = role() === 'owner'
const canRoute = isManagerial()
const toast = useToast()
@ -178,6 +187,7 @@ export function ClientDetail() {
tabs={[
{ key: 'overview', label: 'Overview' },
{ key: 'modules', label: 'Modules', count: (cms.data ?? []).length },
{ key: 'tickets', label: 'Tickets', count: tickets.data?.total ?? 0 },
{ key: 'documents', label: 'Documents', count: docs.length },
{ key: 'payments', label: 'Payments & plans', count: (ledger.data?.payments ?? []).length },
{ key: 'amc', label: 'AMC', count: (amc.data ?? []).length },
@ -214,6 +224,12 @@ export function ClientDetail() {
{docs.length > 5 && (
<Toolbar><Button onClick={() => setTab('documents')}>All {docs.length} documents </Button></Toolbar>
)}
<h3 style={{ marginTop: 18 }}>Branches</h3>
{branches.error !== undefined && <ErrorState message={branches.error} onRetry={branches.reload} />}
{branches.data === undefined && branches.error === undefined ? <Skeleton />
: branches.data !== undefined && (
<BranchList clientId={id} branches={branches.data} onChanged={branches.reload} />
)}
</>
)}
@ -257,6 +273,61 @@ export function ClientDetail() {
})}
/>
)}
{(cms.data ?? []).length > 0 && <h3 style={{ marginTop: 20 }}>Service data</h3>}
{(cms.data ?? []).map((cm) => (
<ServiceDataCard key={cm.id} cm={cm} name={moduleName(cm.moduleId)} onChanged={() => cms.reload()} />
))}
</>
)}
{tab === 'tickets' && (
<>
<Toolbar>
<Button tone="primary" onClick={() => setTicketDialog(true)}>New ticket</Button>
<span style={{ flex: 1 }} />
{tickets.data !== undefined && <Badge>{tickets.data.total} total</Badge>}
</Toolbar>
{tickets.error !== undefined && <ErrorState message={tickets.error} onRetry={tickets.reload} />}
{tickets.data === undefined && tickets.error === undefined ? <Skeleton />
: tickets.data !== undefined && tickets.data.tickets.length === 0
? <EmptyState>No tickets for this client.</EmptyState>
: tickets.data !== undefined && (
<>
<DataTable
columns={[
{ key: 'opened', label: 'Opened' }, { key: 'branch', label: 'Branch' },
{ key: 'module', label: 'Module', mono: true }, { key: 'kind', label: 'Kind' },
{ key: 'desc', label: 'Description' }, { key: 'assigned', label: 'Assigned' },
{ key: 'status', label: 'Status' }, { key: 'act', label: '' },
]}
rows={tickets.data.tickets.map((t) => ({
opened: t.openedOn,
branch: t.branchName ?? '—',
module: t.moduleCode ?? '—',
kind: t.kind !== '' ? t.kind : '—',
desc: <TicketDescription text={t.description} />,
assigned: t.assignedName ?? '—',
status: <Badge tone={TICKET_TONE[t.status]}>{TICKET_STATUS_LABEL[t.status]}</Badge>,
act: <TicketActions t={t} onDone={tickets.reload} onError={setActionErr} />,
}))}
/>
{Math.ceil(tickets.data.total / tickets.data.pageSize) > 1 && (
<div className="wf-pager">
<span>{tickets.data.total} ticket(s)</span>
<Button onClick={() => { if (ticketPage > 1) setTicketPage((p) => p - 1) }}> Prev</Button>
<span>Page {tickets.data.page}/{Math.ceil(tickets.data.total / tickets.data.pageSize)}</span>
<Button onClick={() => { if (ticketPage < Math.ceil(tickets.data!.total / tickets.data!.pageSize)) setTicketPage((p) => p + 1) }}>Next </Button>
</div>
)}
</>
)}
<NewTicketDialog
open={ticketDialog}
onClose={() => setTicketDialog(false)}
onCreated={() => tickets.reload()}
kinds={tickets.data?.kinds ?? []}
initialClient={{ id: c.id, code: c.code, name: c.name }}
/>
</>
)}
@ -582,3 +653,219 @@ function SupportCard(props: { client: Client; onChanged: () => void }) {
</div>
)
}
/**
* Branch book (D20 APEX ho_branch_list parity): list + add + inline rename /
* deactivate. Branches are never deleted (tickets reference them) deactivating
* hides them from pickers while history keeps its name.
*/
function BranchList(props: { clientId: string; branches: Branch[]; onChanged: () => void }) {
const toast = useToast()
const [name, setName] = useState('')
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [edit, setEdit] = useState<{ id: string; name: string; code: string } | undefined>()
const add = () => {
if (busy || name.trim() === '') return
setBusy(true)
createBranch(props.clientId, { name, ...(code.trim() !== '' ? { code } : {}) })
.then(() => { toast.ok('Branch added'); setName(''); setCode(''); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const saveEdit = () => {
if (edit === undefined || busy) return
setBusy(true)
patchBranch(edit.id, { name: edit.name, code: edit.code })
.then(() => { toast.ok('Branch saved'); setEdit(undefined); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const setActive = (id: string, active: boolean) => {
if (busy) return
setBusy(true)
patchBranch(id, { active })
.then(() => { toast.ok(active ? 'Branch reactivated' : 'Branch deactivated'); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
return (
<>
<Toolbar>
<input
className="wf" style={{ maxWidth: 220 }} placeholder="Branch name" aria-label="Branch name"
value={name} onChange={(e) => setName(e.target.value)}
/>
<input
className="wf" style={{ maxWidth: 120 }} placeholder="Code (optional)" aria-label="Branch code"
value={code} onChange={(e) => setCode(e.target.value)}
/>
<Button tone="primary" disabled={busy || name.trim() === ''} onClick={add}>Add branch</Button>
</Toolbar>
{props.branches.length === 0 ? <EmptyState>No branches head office only.</EmptyState> : (
<DataTable
columns={[
{ key: 'name', label: 'Name' }, { key: 'code', label: 'Code', mono: true },
{ key: 'active', label: 'Active' }, { key: 'act', label: '' },
]}
rows={props.branches.map((b) => {
const editing = edit !== undefined && edit.id === b.id
return {
name: editing
? <input className="wf" aria-label="Name" value={edit.name} onChange={(e) => setEdit((p) => p && ({ ...p, name: e.target.value }))} />
: b.name,
code: editing
? <input className="wf" aria-label="Code" style={{ width: 100 }} value={edit.code} onChange={(e) => setEdit((p) => p && ({ ...p, code: e.target.value }))} />
: (b.code ?? '—'),
active: b.active ? <Badge tone="ok">active</Badge> : <Badge>inactive</Badge>,
act: editing
? (
<span style={{ display: 'flex', gap: 6 }}>
<Button tone="primary" disabled={busy} onClick={saveEdit}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => setEdit(undefined)}>Cancel</Button>
</span>
)
: (
<span style={{ display: 'flex', gap: 6 }}>
<Button onClick={() => setEdit({ id: b.id, name: b.name, code: b.code ?? '' })}>Rename</Button>
{b.active
? <Button tone="danger" disabled={busy} onClick={() => setActive(b.id, false)}>Deactivate</Button>
: <Button disabled={busy} onClick={() => setActive(b.id, true)}>Reactivate</Button>}
</span>
),
}
})}
/>
)}
</>
)
}
/**
* Per-service operational data (D20 APEX sms_clients_list parity), following
* SupportCard's exact visual pattern: label/value cells, the portal password as
* with managerial audited reveal (gated like the DB password), and Edit
* switching provider/username/remark + the labeled details into inputs one
* audited PATCH saves the lot.
*/
function ServiceDataCard(props: { cm: ClientModule; name: string; onChanged: () => void }) {
const toast = useToast()
const managerial = isManagerial()
const cm = props.cm
const [revealed, setRevealed] = useState<string | undefined>()
const [settingPw, setSettingPw] = useState(false)
const [pw, setPw] = useState('')
const [busy, setBusy] = useState(false)
const [editing, setEditing] = useState(false)
const [f, setF] = useState({ provider: '', username: '', remark: '' })
const [details, setDetails] = useState<ServiceDetail[]>([])
const copy = (text: string, what: string) => {
navigator.clipboard.writeText(text).then(() => toast.ok(`${what} copied`)).catch(() => toast.err('Copy failed'))
}
const reveal = () => {
if (busy) return
setBusy(true)
revealModulePassword(cm.id)
.then((p) => setRevealed(p))
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const savePw = () => {
if (busy || pw === '') return
setBusy(true)
patchClientModule(cm.id, { password: pw })
.then(() => { toast.ok('Portal password stored (encrypted)'); setSettingPw(false); setPw(''); setRevealed(undefined); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const startEdit = () => {
setF({ provider: cm.provider ?? '', username: cm.username ?? '', remark: cm.remark ?? '' })
setDetails(cm.details.map((d) => ({ ...d })))
setEditing(true)
}
const saveEdit = () => {
if (busy) return
const cleaned = details.filter((d) => d.label.trim() !== '' || d.value.trim() !== '')
if (cleaned.some((d) => d.label.trim() === '')) { toast.err('Every detail needs a label'); return }
setBusy(true)
patchClientModule(cm.id, {
provider: f.provider, username: f.username, remark: f.remark,
details: cleaned.map((d) => ({ label: d.label.trim(), value: d.value })),
})
.then(() => { toast.ok('Service data saved'); setEditing(false); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const cell = (label: string, value: string | undefined, mono = false, copyable = false) => (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6, marginRight: 18 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>{label}</span>
<span className={mono ? 'mono' : undefined}>{value ?? '—'}</span>
{copyable && value !== undefined && <Button onClick={() => copy(value, label)}>Copy</Button>}
</span>
)
if (editing) {
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{props.name} service data</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
<Field label="Provider"><input className="wf" style={{ width: 140 }} value={f.provider} onChange={(e) => setF((p) => ({ ...p, provider: e.target.value }))} /></Field>
<Field label="Username"><input className="wf mono" style={{ width: 160 }} value={f.username} onChange={(e) => setF((p) => ({ ...p, username: e.target.value }))} /></Field>
<Field label="Remark"><input className="wf" style={{ width: 220 }} value={f.remark} onChange={(e) => setF((p) => ({ ...p, remark: e.target.value }))} /></Field>
</div>
{details.map((d, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input
className="wf" placeholder="Label" style={{ maxWidth: 180 }} value={d.label}
onChange={(e) => setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))}
/>
<input
className="wf" placeholder="Value" value={d.value}
onChange={(e) => setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)))}
/>
<Button onClick={() => setDetails((prev) => prev.filter((_x, j) => j !== i))}></Button>
</div>
))}
<div style={{ display: 'flex', gap: 6 }}>
<Button onClick={() => setDetails((prev) => [...prev, { label: '', value: '' }])}>+ Add detail</Button>
<span style={{ flex: 1 }} />
<Button tone="primary" disabled={busy} onClick={saveEdit}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => setEditing(false)}>Cancel</Button>
</div>
</div>
)
}
return (
<div className="wf-card" style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8, padding: '10px 14px' }}>
<span style={{ fontWeight: 600, marginRight: 10 }}>{props.name}</span>
{cell('Provider', cm.provider ?? undefined)}
{cell('Username', cm.username ?? undefined, true, true)}
{cm.details.map((d, i) => <Fragment key={i}>{cell(d.label, d.value)}</Fragment>)}
{cell('Remark', cm.remark ?? undefined)}
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>Portal password</span>
{revealed !== undefined
? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed, 'Portal password')}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></>
: cm.hasPassword
? <><span className="mono"></span>{managerial && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
: <span style={{ opacity: 0.6 }}>not set</span>}
{managerial && !settingPw && <Button onClick={() => setSettingPw(true)}>{cm.hasPassword ? 'Update' : 'Set…'}</Button>}
{managerial && settingPw && (
<>
<input className="wf mono" type="password" style={{ width: 160 }} placeholder="Portal password"
value={pw} onChange={(e) => setPw(e.target.value)} />
<Button tone="primary" onClick={savePw}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSettingPw(false); setPw('') }}>Cancel</Button>
</>
)}
</span>
<span style={{ flex: 1 }} />
<Button onClick={startEdit}>Edit</Button>
</div>
)
}

@ -0,0 +1,372 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
Badge, Button, ConfirmDialog, DataTable, Dialog, EmptyState, ErrorState, FilterChips,
FormField, FormGrid, Notice, PageHeader, Skeleton, Toolbar, useToast,
} from '@sims/ui'
import {
createTicket, getBranches, getClients, getEmployees, getTickets, patchTicket, staffId,
TICKET_STATUSES,
type Branch, type Client, type Employee, type Ticket, type TicketStatus,
} from '../api'
import { useData } from './Clients'
const PAGE_SIZE = 50
/** The five APEX service lines (D20 design §1) — suggestions only; module stays free text. */
export const TICKET_MODULE_CODES = ['SMS', 'RTGS', 'WHATSAPP', 'MOBILEAPP', 'ATM']
export const TICKET_STATUS_LABEL: Record<TicketStatus, string> = {
open: 'Open', in_progress: 'In progress', waiting: 'Waiting', closed: 'Closed', dropped: 'Dropped',
}
export const TICKET_TONE: Record<TicketStatus, 'ok' | 'warn' | 'err' | 'accent' | undefined> = {
open: 'accent', in_progress: 'warn', waiting: undefined, closed: 'ok', dropped: 'err',
}
/** Description cell: truncated with the full text on hover (title). */
export function TicketDescription(props: { text: string }) {
if (props.text === '') return <></>
return (
<span
title={props.text}
style={{
display: 'inline-block', maxWidth: 340, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap', verticalAlign: 'bottom',
}}
>
{props.text}
</span>
)
}
/**
* The workbench transitions: openStart/Waiting, in progressWaiting/Close,
* waitingStart/Close, terminalReopen; Drop asks first (it stays on record).
*/
export function TicketActions(props: { t: Ticket; onDone: () => void; onError: (m: string) => void }) {
const [busy, setBusy] = useState(false)
const [dropping, setDropping] = useState(false)
const go = (status: TicketStatus) => {
if (busy) return
setBusy(true)
patchTicket(props.t.id, { status })
.then(props.onDone)
.catch((e: Error) => props.onError(e.message))
.finally(() => setBusy(false))
}
const s = props.t.status
return (
<span style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
{(s === 'open' || s === 'waiting') && <Button tone="primary" disabled={busy} onClick={() => go('in_progress')}>Start</Button>}
{(s === 'open' || s === 'in_progress') && <Button disabled={busy} onClick={() => go('waiting')}>Waiting</Button>}
{(s === 'in_progress' || s === 'waiting') && <Button tone="primary" disabled={busy} onClick={() => go('closed')}>Close</Button>}
{(s === 'closed' || s === 'dropped') && <Button disabled={busy} onClick={() => go('open')}>Reopen</Button>}
{s !== 'closed' && s !== 'dropped' && (
<Button tone="danger" disabled={busy} onClick={() => setDropping(true)}>Drop</Button>
)}
{dropping && (
<ConfirmDialog
open
onClose={() => setDropping(false)}
onConfirm={() => { setDropping(false); go('dropped') }}
title="Drop ticket"
body={`Drop this ${props.t.kind !== '' ? `${props.t.kind} ` : ''}ticket for ${props.t.clientName}? It stays on record as dropped and can be reopened.`}
confirmLabel="Drop"
tone="danger"
/>
)}
</span>
)
}
/** Per-row assignment — '' = unassigned (PATCH sends null to clear). */
export function TicketAssignSelect(props: {
t: Ticket; employees: Employee[]; onDone: () => void; onError: (m: string) => void
}) {
return (
<select
className="wf" aria-label="Assigned to" value={props.t.assignedTo ?? ''}
onChange={(e) => {
patchTicket(props.t.id, { assignedTo: e.target.value !== '' ? e.target.value : null })
.then(props.onDone).catch((err: Error) => props.onError(err.message))
}}
>
<option value=""> unassigned </option>
{props.employees
.filter((emp) => emp.active || emp.id === props.t.assignedTo)
.map((emp) => <option key={emp.id} value={emp.id}>{emp.displayName}</option>)}
</select>
)
}
/**
* New-ticket dialog. Client via type-ahead (Modules assign pattern) unless the
* caller pre-picks one (Client 360 tab); branches load once a client is picked;
* module and kind are free text with suggestions (config over code kinds come
* from history via the list response).
*/
export function NewTicketDialog(props: {
open: boolean; onClose: () => void; onCreated: () => void
kinds: string[]
/** Pre-picked client (Client 360) — hides the type-ahead. */
initialClient?: { id: string; code: string; name: string }
}) {
const toast = useToast()
const [q, setQ] = useState('')
const [hits, setHits] = useState<Client[]>([])
const [pick, setPick] = useState<{ id: string; code: string; name: string } | undefined>()
const [branches, setBranches] = useState<Branch[]>([])
const [f, setF] = useState({ branchId: '', moduleCode: '', kind: '', description: '', onlineOffline: '' })
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!props.open) return
setError(undefined)
setQ(''); setHits([]); setPick(props.initialClient)
setF({ branchId: '', moduleCode: '', kind: '', description: '', onlineOffline: '' })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.open, props.initialClient?.id])
useEffect(() => {
if (pick === undefined) { setBranches([]); return }
getBranches(pick.id).then(setBranches).catch(() => setBranches([]))
}, [pick])
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value }))
const search = (text: string) => {
setQ(text); setPick(undefined); setF((p) => ({ ...p, branchId: '' }))
if (text.trim().length < 2) { setHits([]); return }
getClients(text).then((cs) => setHits(cs.slice(0, 6))).catch(() => setHits([]))
}
const save = () => {
if (saving) return
if (pick === undefined) { setError('Pick a client'); return }
setError(undefined)
setSaving(true)
createTicket({
clientId: pick.id,
...(f.branchId !== '' ? { branchId: f.branchId } : {}),
...(f.moduleCode.trim() !== '' ? { moduleCode: f.moduleCode.trim() } : {}),
...(f.kind.trim() !== '' ? { kind: f.kind.trim() } : {}),
...(f.description.trim() !== '' ? { description: f.description.trim() } : {}),
...(f.onlineOffline !== '' ? { onlineOffline: f.onlineOffline } : {}),
})
.then(() => { toast.ok('Ticket created'); props.onCreated(); props.onClose() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
}
return (
<Dialog
open={props.open}
onClose={() => { if (!saving) props.onClose() }}
title="New ticket"
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Create ticket'}</Button>
</>
}
>
<FormGrid>
<FormField label="Client" wide>
{props.initialClient !== undefined
? <input className="wf" value={`${props.initialClient.code} · ${props.initialClient.name}`} disabled />
: (
<>
<input
className="wf" autoFocus placeholder="Search name / code…"
value={pick !== undefined ? `${pick.code} · ${pick.name}` : q}
onChange={(e) => search(e.target.value)}
/>
{pick === undefined && hits.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 6 }}>
{hits.map((c) => (
<Button key={c.id} onClick={() => setPick({ id: c.id, code: c.code, name: c.name })}>{c.code} · {c.name}</Button>
))}
</div>
)}
</>
)}
</FormField>
<FormField label="Branch">
<select
className="wf" value={f.branchId} onChange={set('branchId')}
disabled={pick === undefined || branches.length === 0}
>
<option value="">
{pick === undefined ? 'Pick a client first' : branches.length === 0 ? 'No branches' : '— head office —'}
</option>
{branches.filter((b) => b.active).map((b) => (
<option key={b.id} value={b.id}>{b.name}{b.code !== null ? ` (${b.code})` : ''}</option>
))}
</select>
</FormField>
<FormField label="Module">
<input
className="wf" list="hq-ticket-modules" placeholder="SMS / RTGS / …"
value={f.moduleCode} onChange={set('moduleCode')}
/>
</FormField>
<FormField label="Kind">
<input
className="wf" list="hq-ticket-kinds" placeholder="e.g. NEW WORK"
value={f.kind} onChange={set('kind')}
/>
</FormField>
<FormField label="Online / offline">
<select className="wf" value={f.onlineOffline} onChange={set('onlineOffline')}>
<option value=""></option>
<option value="online">online</option>
<option value="offline">offline</option>
</select>
</FormField>
<FormField label="Description" wide>
<textarea className="wf" rows={3} value={f.description} onChange={set('description')} />
</FormField>
</FormGrid>
<datalist id="hq-ticket-modules">
{TICKET_MODULE_CODES.map((m) => <option key={m} value={m} />)}
</datalist>
<datalist id="hq-ticket-kinds">
{props.kinds.map((k) => <option key={k} value={k} />)}
</datalist>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</Dialog>
)
}
/**
* Ticket desk (D20 APEX work-bench parity): team-visible, "Mine" is a filter
* not a gate (support staff pick up unassigned work). Chips carry whole-desk
* per-status counts; search hits description/kind/client; honest pagination.
*/
export function Tickets() {
const [status, setStatus] = useState('open')
const [scope, setScope] = useState<'all' | 'mine'>('all')
const [q, setQ] = useState('')
const [page, setPage] = useState(1)
const [err, setErr] = useState('')
const [creating, setCreating] = useState(false)
const me = staffId()
const employees = useData(() => getEmployees(), [])
const list = useData(
() => getTickets({
...(status !== 'all' ? { status: status as TicketStatus } : {}),
...(scope === 'mine' ? { mine: true } : {}),
...(q !== '' ? { q } : {}),
page, pageSize: PAGE_SIZE,
}),
[status, scope, q, page],
)
const data = list.data
const rows = data?.tickets
const counts = data?.counts
const allCount = counts !== undefined ? Object.values(counts).reduce((a, b) => a + b, 0) : 0
const totalPages = data !== undefined ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1
const from = data !== undefined && data.total > 0 ? (data.page - 1) * data.pageSize + 1 : 0
const to = data !== undefined ? Math.min(data.page * data.pageSize, data.total) : 0
return (
<div className="wf-page">
<PageHeader
title="Tickets"
desc="The support work bench — every service call across all clients. Anyone can pick up unassigned work."
actions={<Button tone="primary" onClick={() => setCreating(true)}>New ticket</Button>}
/>
<Toolbar>
<FilterChips
chips={[
{ key: 'all', label: 'All', count: allCount },
...TICKET_STATUSES.map((s) => ({
key: s, label: TICKET_STATUS_LABEL[s], count: counts?.[s] ?? 0,
})),
]}
active={status}
onChange={(k) => { setStatus(k); setPage(1) }}
/>
{me !== '' && (
<FilterChips
chips={[{ key: 'all', label: 'All' }, { key: 'mine', label: 'Mine' }]}
active={scope}
onChange={(k) => { setScope(k as 'all' | 'mine'); setPage(1) }}
/>
)}
<input
className="wf" style={{ maxWidth: 240 }} placeholder="Search description / kind / client…"
value={q} onChange={(e) => { setQ(e.target.value); setPage(1) }}
/>
<span style={{ flex: 1 }} />
{data !== undefined && <Badge>{data.total} total</Badge>}
</Toolbar>
{err !== '' && <Notice tone="err">{err}</Notice>}
{list.error !== undefined && <ErrorState message={list.error} onRetry={list.reload} />}
{list.error !== undefined ? null
: rows === undefined ? <Skeleton rows={6} />
: rows.length === 0 ? (
<EmptyState>
No {status !== 'all' ? TICKET_STATUS_LABEL[status as TicketStatus].toLowerCase() : ''} tickets
{scope === 'mine' ? ' of yours' : ''}{q !== '' ? ` matching “${q}` : ''}.
</EmptyState>
) : (
<>
<DataTable
columns={[
{ key: 'opened', label: 'Opened' },
{ key: 'client', label: 'Client' },
{ key: 'branch', label: 'Branch' },
{ key: 'module', label: 'Module', mono: true },
{ key: 'kind', label: 'Kind' },
{ key: 'desc', label: 'Description' },
{ key: 'assigned', label: 'Assigned' },
{ key: 'status', label: 'Status' },
{ key: 'act', label: '' },
]}
rows={rows.map((t) => ({
opened: t.openedOn,
client: <Link to={`/clients/${t.clientId}`}>{t.clientName}</Link>,
branch: t.branchName ?? '—',
module: t.moduleCode ?? '—',
kind: t.kind !== '' ? t.kind : '—',
desc: <TicketDescription text={t.description} />,
assigned: employees.data !== undefined
? <TicketAssignSelect t={t} employees={employees.data.employees} onDone={list.reload} onError={setErr} />
: (t.assignedName ?? '—'),
status: <Badge tone={TICKET_TONE[t.status]}>{TICKET_STATUS_LABEL[t.status]}</Badge>,
act: <TicketActions t={t} onDone={list.reload} onError={setErr} />,
}))}
/>
<Toolbar>
<Badge>Showing {from}{to} of {data!.total}</Badge>
<span style={{ flex: 1 }} />
<button
type="button" className="wf" disabled={data!.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span style={{ fontSize: 12, opacity: 0.7 }}>Page {data!.page} / {totalPages}</span>
<button
type="button" className="wf" disabled={data!.page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</Toolbar>
</>
)}
<NewTicketDialog
open={creating}
onClose={() => setCreating(false)}
onCreated={() => list.reload()}
kinds={data?.kinds ?? []}
/>
</div>
)
}
Loading…
Cancel
Save