You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/pages/Tickets.tsx

373 lines
15 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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: open→Start/Waiting, in progress→Waiting/Close,
* waiting→Start/Close, terminal→Reopen; 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>
)
}