|
|
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>
|
|
|
)
|
|
|
}
|