diff --git a/.gitignore b/.gitignore index 3907c6c..73af595 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 7dd9030..e26de09 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -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 => export const assignClientModule = (clientId: string, body: Record): Promise => apiFetch<{ clientModule: ClientModule }>(`/clients/${clientId}/modules`, { method: 'POST', body: JSON.stringify(body) }) .then((r) => r.clientModule) -export const patchClientModule = (id: string, body: Record): Promise => +export const patchClientModule = (id: string, body: ClientModulePatch): Promise => 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 => + 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; 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 => { + 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(`/tickets${q === '' ? '' : `?${q}`}`) +} +export const createTicket = (body: { + clientId: string; branchId?: string; moduleCode?: string; kind?: string + description?: string; onlineOffline?: string; assignedTo?: string +}): Promise => + 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 => + 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 => + apiFetch<{ branches: Branch[] }>(`/clients/${clientId}/branches`).then((r) => r.branches) +export const createBranch = (clientId: string, body: { name: string; code?: string }): Promise => + 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 => + 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') diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index 163af04..f84e783 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts index 8b4660a..549260b 100644 --- a/apps/hq-web/src/nav.ts +++ b/apps/hq-web/src/nav.ts @@ -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 }, diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index e1bf62f..3ef711e 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -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 = { paid: 'ok', unpaid: 'err', unbilled: 'warn' } const OUTCOME_TONE: Record = { 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 && ( )} +

Branches

+ {branches.error !== undefined && } + {branches.data === undefined && branches.error === undefined ? + : branches.data !== undefined && ( + + )} )} @@ -257,6 +273,61 @@ export function ClientDetail() { })} /> )} + {(cms.data ?? []).length > 0 &&

Service data

} + {(cms.data ?? []).map((cm) => ( + cms.reload()} /> + ))} + + )} + + {tab === 'tickets' && ( + <> + + + + {tickets.data !== undefined && {tickets.data.total} total} + + {tickets.error !== undefined && } + {tickets.data === undefined && tickets.error === undefined ? + : tickets.data !== undefined && tickets.data.tickets.length === 0 + ? No tickets for this client. + : tickets.data !== undefined && ( + <> + ({ + opened: t.openedOn, + branch: t.branchName ?? '—', + module: t.moduleCode ?? '—', + kind: t.kind !== '' ? t.kind : '—', + desc: , + assigned: t.assignedName ?? '—', + status: {TICKET_STATUS_LABEL[t.status]}, + act: , + }))} + /> + {Math.ceil(tickets.data.total / tickets.data.pageSize) > 1 && ( +
+ {tickets.data.total} ticket(s) + + Page {tickets.data.page}/{Math.ceil(tickets.data.total / tickets.data.pageSize)} + +
+ )} + + )} + 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 }) { ) } + +/** + * 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 ( + <> + + setName(e.target.value)} + /> + setCode(e.target.value)} + /> + + + {props.branches.length === 0 ? No branches — head office only. : ( + { + const editing = edit !== undefined && edit.id === b.id + return { + name: editing + ? setEdit((p) => p && ({ ...p, name: e.target.value }))} /> + : b.name, + code: editing + ? setEdit((p) => p && ({ ...p, code: e.target.value }))} /> + : (b.code ?? '—'), + active: b.active ? active : inactive, + act: editing + ? ( + + + + + ) + : ( + + + {b.active + ? + : } + + ), + } + })} + /> + )} + + ) +} + +/** + * 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() + 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([]) + + 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) => ( + + {label} + {value ?? '—'} + {copyable && value !== undefined && } + + ) + + if (editing) { + return ( +
+
{props.name} — service data
+
+ setF((p) => ({ ...p, provider: e.target.value }))} /> + setF((p) => ({ ...p, username: e.target.value }))} /> + setF((p) => ({ ...p, remark: e.target.value }))} /> +
+ {details.map((d, i) => ( +
+ setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} + /> + setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)))} + /> + +
+ ))} +
+ + + + +
+
+ ) + } + + return ( +
+ {props.name} + {cell('Provider', cm.provider ?? undefined)} + {cell('Username', cm.username ?? undefined, true, true)} + {cm.details.map((d, i) => {cell(d.label, d.value)})} + {cell('Remark', cm.remark ?? undefined)} + + Portal password + {revealed !== undefined + ? <>{revealed} + : cm.hasPassword + ? <>••••••••{managerial && } + : not set} + {managerial && !settingPw && } + {managerial && settingPw && ( + <> + setPw(e.target.value)} /> + + + + )} + + + +
+ ) +} diff --git a/apps/hq-web/src/pages/Tickets.tsx b/apps/hq-web/src/pages/Tickets.tsx new file mode 100644 index 0000000..d33331b --- /dev/null +++ b/apps/hq-web/src/pages/Tickets.tsx @@ -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 = { + open: 'Open', in_progress: 'In progress', waiting: 'Waiting', closed: 'Closed', dropped: 'Dropped', +} + +export const TICKET_TONE: Record = { + 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 ( + + {props.text} + + ) +} + +/** + * 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 ( + + {(s === 'open' || s === 'waiting') && } + {(s === 'open' || s === 'in_progress') && } + {(s === 'in_progress' || s === 'waiting') && } + {(s === 'closed' || s === 'dropped') && } + {s !== 'closed' && s !== 'dropped' && ( + + )} + {dropping && ( + 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" + /> + )} + + ) +} + +/** Per-row assignment — '' = unassigned (PATCH sends null to clear). */ +export function TicketAssignSelect(props: { + t: Ticket; employees: Employee[]; onDone: () => void; onError: (m: string) => void +}) { + return ( + + ) +} + +/** + * 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([]) + const [pick, setPick] = useState<{ id: string; code: string; name: string } | undefined>() + const [branches, setBranches] = useState([]) + const [f, setF] = useState({ branchId: '', moduleCode: '', kind: '', description: '', onlineOffline: '' }) + const [error, setError] = useState() + 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 ( + { if (!saving) props.onClose() }} + title="New ticket" + footer={ + <> + + + + } + > + + + {props.initialClient !== undefined + ? + : ( + <> + search(e.target.value)} + /> + {pick === undefined && hits.length > 0 && ( +
+ {hits.map((c) => ( + + ))} +
+ )} + + )} +
+ + + + + + + + + + + + + +