merge: frontend — quotation-step dialog, ticket types + deep-links, cleanup

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Thomas Joise 1 day ago
commit ac16d8c896

@ -378,23 +378,29 @@ export const revealModuleSecret = (id: string, key: string): Promise<string> =>
/** One checklist step on a project (client_module). Ticking stamps `doneOn`; the
* advance/balance payment steps additionally carry `paymentId`, linking a real
* payment from the client's ledger. */
* payment from the client's ledger, and the quotation step carries `documentId`,
* linking the quotation/proforma that was actually sent. */
export interface Milestone {
key: string; label: string; done: boolean; doneOn: string | null; sort: number; paymentId: string | null
key: string; label: string; done: boolean; doneOn: string | null; sort: number
paymentId: string | null; documentId: string | null
}
/** The project's checklist; the server auto-seeds the standard template on first read. */
export const getMilestones = (clientModuleId: string): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`).then((r) => r.milestones)
/** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it.
* `paymentId` links a real payment to a step (advance_payment / balance_payment). */
* `paymentId` links a real payment to a step (advance_payment / balance_payment);
* `documentId` links a real document to a step (quotation_sent). Un-ticking clears both. */
export const setMilestone = (
clientModuleId: string, key: string, done: boolean, doneOn?: string, paymentId?: string,
clientModuleId: string, key: string, done: boolean, doneOn?: string, paymentId?: string, documentId?: string,
): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, {
method: 'POST',
body: JSON.stringify({
key, done, ...(doneOn !== undefined ? { doneOn } : {}), ...(paymentId !== undefined ? { paymentId } : {}),
key, done,
...(doneOn !== undefined ? { doneOn } : {}),
...(paymentId !== undefined ? { paymentId } : {}),
...(documentId !== undefined ? { documentId } : {}),
}),
}).then((r) => r.milestones)
/** Add a project-specific step beyond the standard template. */
@ -441,6 +447,8 @@ export interface Ticket {
id: string; clientId: string; clientName: string
branchId: string | null; branchName: string | null
moduleCode: string | null; kind: string; description: string
/** Classification key from the `ticket.types` setting (service / module / …). */
type: string
status: TicketStatus
assignedTo: string | null; assignedName: string | null
onlineOffline: string | null
@ -448,6 +456,20 @@ export interface Ticket {
createdBy: string; createdAt: string; source: string
}
/** One ticket classification (config over code — the `ticket.types` setting row). */
export interface TicketType { key: string; label: string }
/** Fallback used only while the setting-backed list is loading / unreachable
* the server's `ticket.types` setting row stays the source of truth. */
export const TICKET_TYPE_FALLBACK: TicketType[] = [
{ key: 'service', label: 'Service' }, { key: 'module', label: 'Module issue' },
{ key: 'modification', label: 'Modification' }, { key: 'amc', label: 'AMC' },
{ key: 'other', label: 'Other' },
]
export const getTicketTypes = (): Promise<TicketType[]> =>
apiFetch<{ ok: boolean; types: TicketType[] }>('/tickets/types').then((r) => r.types)
export interface TicketsPage {
tickets: Ticket[]; total: number; page: number; pageSize: number
/** Whole-desk per-status counts (chips) + every kind seen so far (datalist suggestions). */
@ -460,11 +482,14 @@ export interface TicketsPage {
export const getTickets = (
opts: {
status?: TicketStatus; mine?: boolean; clientId?: string; module?: string
/** Classification filter — omit / blank means every type. */
type?: string
overdue?: boolean; q?: string; page?: number; pageSize?: number
} = {},
): Promise<TicketsPage> => {
const p = new URLSearchParams()
if (opts.status !== undefined) p.set('status', opts.status)
if (opts.type !== undefined && opts.type !== '') p.set('type', opts.type)
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)
@ -476,13 +501,13 @@ export const getTickets = (
return apiFetch<TicketsPage>(`/tickets${q === '' ? '' : `?${q}`}`)
}
export const createTicket = (body: {
clientId: string; branchId?: string; moduleCode?: string; kind?: string
clientId: string; branchId?: string; moduleCode?: string; kind?: string; type?: 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
status?: TicketStatus; assignedTo?: string | null; kind?: string; type?: 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)

@ -518,6 +518,5 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse
.cdr-out .orow { display: flex; align-items: center; gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px; }
.cdr-out .orow:last-child { border-bottom: none; }
.cdr-out .orow .due { margin-left: auto; font-family: var(--mono); font-weight: 600; }
.cdr-out .ofoot { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-inset); }
/* document-type pill */
.doc-pill { font-size: 10.5px; font-weight: 600; letter-spacing: .3px; border-radius: 999px; padding: 1px 8px; border: 1px solid var(--border); color: var(--text-dim); background: var(--bg-inset); }

@ -65,7 +65,9 @@ export function ClientDetail() {
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)
// Undefined = closed. `moduleCode` pre-fills the module when the ticket is raised from
// a module block ("Raise ticket for this module") rather than the Tickets tab button.
const [ticketDialog, setTicketDialog] = useState<{ moduleCode?: string } | undefined>()
const isOwner = role() === 'owner'
const canRoute = isManagerial()
const toast = useToast()
@ -88,6 +90,11 @@ export function ClientDetail() {
const [credsDialog, setCredsDialog] = useState<{ cm: ClientModule; key: string; label: string } | undefined>()
const [savingCreds, setSavingCreds] = useState(false)
const [credsError, setCredsError] = useState<string | undefined>()
// Ticking the quotation step opens this document picker instead of a bare toggle — same
// state shape as the payment picker above (the ledger is already loaded here).
const [docPicker, setDocPicker] = useState<{ clientModuleId: string; key: string; label: string } | undefined>()
const [linkingDoc, setLinkingDoc] = useState(false)
const [docLinkError, setDocLinkError] = useState<string | undefined>()
const rawTab = sp.get('tab') ?? 'overview'
const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview'
@ -312,8 +319,14 @@ export function ClientDetail() {
<span className="mod-name">{moduleName(cm.moduleId)}</span>
<Badge tone={cm.status === 'live' ? 'ok' : undefined}>{cm.status}</Badge>
<Badge>{KIND_LABEL[cm.kind]}</Badge>
<span onClick={(e) => e.stopPropagation()}>
<span onClick={(e) => e.stopPropagation()} style={{ display: 'inline-flex', gap: 6 }}>
<ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} />
{/* Support calls arrive per module — raise one without leaving the block. */}
<Button onClick={() => setTicketDialog({
moduleCode: modules.data?.find((m) => m.id === cm.moduleId)?.code ?? '',
})}>
Raise ticket for this module
</Button>
</span>
</summary>
<div className="mod-section-body">
@ -329,6 +342,7 @@ export function ClientDetail() {
refreshToken={milestonesVersion}
onTickPayment={(key, label) => { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }}
onTickCreds={(key, label) => { setCredsError(undefined); setCredsDialog({ cm, key, label }) }}
onTickDocument={(key, label) => { setDocLinkError(undefined); setDocPicker({ clientModuleId: cm.id, key, label }) }}
/>
<ModuleDocuments
cm={cm}
@ -345,7 +359,7 @@ export function ClientDetail() {
{tab === 'tickets' && (
<>
<Toolbar>
<Button tone="primary" onClick={() => setTicketDialog(true)}>New ticket</Button>
<Button tone="primary" onClick={() => setTicketDialog({})}>New ticket</Button>
<span style={{ flex: 1 }} />
{tickets.data !== undefined && <Badge>{tickets.data.total} total</Badge>}
</Toolbar>
@ -365,7 +379,10 @@ export function ClientDetail() {
rows={tickets.data.tickets.map((t) => ({
opened: t.openedOn,
branch: t.branchName ?? '—',
module: t.moduleCode ?? '—',
// Deep-link to this client's Modules tab — the project behind the ticket.
module: t.moduleCode !== null && t.moduleCode !== ''
? <Link to={`/clients/${id}?tab=modules`}>{t.moduleCode}</Link>
: '—',
kind: t.kind !== '' ? t.kind : '—',
desc: <TicketDescription text={t.description} />,
assigned: t.assignedName ?? '—',
@ -383,13 +400,6 @@ export function ClientDetail() {
)}
</>
)}
<NewTicketDialog
open={ticketDialog}
onClose={() => setTicketDialog(false)}
onCreated={() => tickets.reload()}
kinds={tickets.data?.kinds ?? []}
initialClient={{ id: c.id, code: c.code, name: c.name }}
/>
</>
)}
@ -677,6 +687,40 @@ export function ClientDetail() {
onRecordNew={() => { setPaymentPicker(undefined); setTab('payments') }}
/>
)}
{docPicker !== undefined && (
<DocumentPickerDialog
open
stepLabel={docPicker.label}
docs={docsNewest.filter((d) => d.docType === 'QUOTATION' || d.docType === 'PROFORMA')}
saving={linkingDoc}
error={docLinkError}
onClose={() => { if (!linkingDoc) setDocPicker(undefined) }}
onPick={(documentId) => {
if (linkingDoc) return
setLinkingDoc(true)
setDocLinkError(undefined)
setMilestone(docPicker.clientModuleId, docPicker.key, true, undefined, undefined, documentId)
.then(() => {
toast.ok('Document linked')
setMilestonesVersion((v) => v + 1)
setDocPicker(undefined)
})
.catch((e: Error) => setDocLinkError(e.message))
.finally(() => setLinkingDoc(false))
}}
onCreateNew={() => { setDocPicker(undefined); nav(`/documents/new?client=${id}`) }}
/>
)}
{/* Raised either from the Tickets tab or from a module block ("Raise ticket for this
module"), so it lives outside the tab switch the module code pre-fills. */}
<NewTicketDialog
open={ticketDialog !== undefined}
onClose={() => setTicketDialog(undefined)}
onCreated={() => tickets.reload()}
kinds={tickets.data?.kinds ?? []}
initialClient={{ id: c.id, code: c.code, name: c.name }}
{...(ticketDialog?.moduleCode !== undefined ? { initialModuleCode: ticketDialog.moduleCode } : {})}
/>
{credsDialog !== undefined && (
<CredentialsDialog
open
@ -1344,17 +1388,28 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
* project's milestones as a point-by-point track (`.cdr-track`) a connector line
* joins ordered dots, the current (first not-done) step is ringed ("now"), done steps
* show a check and their date. Ticking a plain step stamps today's date and drives the
* module status (installed/live) exactly as before; ticking the advance/balance payment
* steps instead calls `onTickPayment` so the parent can open a picker that links a real
* ledger payment (`ClientDetail`'s `PaymentPickerDialog`) rather than a bare date.
* module status (installed/live) exactly as before. Three steps instead hand off to a
* dialog the parent (`ClientDetail`) owns, because each needs data this component does
* not hold:
* - the advance/balance **payment** steps call `onTickPayment` `PaymentPickerDialog`,
* which links a real ledger payment (`setMilestone(..., paymentId)`) rather than
* stamping a bare date;
* - the `account_creation` **credentials** step calls `onTickCreds`
* `CredentialsDialog`, which captures the module's provider/username/password
* (managerial gate) at the moment the account actually exists;
* - the `quotation_sent` **document** step calls `onTickDocument`
* `DocumentPickerDialog`, which links the quotation/proforma that was sent
* (`setMilestone(..., documentId)`) from the client's already-loaded ledger.
* Un-ticking any of them toggles directly (the server clears the link).
* "+ Add step" appends a project-specific milestone. Every change is one audited POST
* that returns the fresh list; `refreshToken` lets the parent force a refetch after a
* payment link lands from outside this component.
* link lands from outside this component.
*/
function StatusLine(props: {
clientModuleId: string; name: string; refreshToken: number
onTickPayment: (key: string, label: string) => void
onTickCreds: (key: string, label: string) => void
onTickDocument: (key: string, label: string) => void
}) {
const toast = useToast()
const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId, props.refreshToken])
@ -1386,15 +1441,18 @@ function StatusLine(props: {
const currentKey = ms?.find((m) => !m.done)?.key
const isPaymentStep = (key: string) => key === 'advance_payment' || key === 'balance_payment'
const isCredsStep = (key: string) => key === 'account_creation'
const isDocumentStep = (key: string) => key === 'quotation_sent'
// Ticking ON a payment step opens the picker (parent owns that dialog — it needs the
// client's ledger + the Payments tab); ticking ON account_creation opens the parent's
// credentials dialog (it needs the module's provider/username/password + managerial
// gate). Un-ticking and every other step toggle directly.
// gate); ticking ON quotation_sent opens the document picker (it needs the ledger's
// documents). Un-ticking and every other step toggle directly.
const onDotClick = (m: Milestone) => {
if (busyKey !== '') return
if (!m.done && isPaymentStep(m.key)) { props.onTickPayment(m.key, m.label); return }
if (!m.done && isCredsStep(m.key)) { props.onTickCreds(m.key, m.label); return }
if (!m.done && isDocumentStep(m.key)) { props.onTickDocument(m.key, m.label); return }
toggle(m, !m.done)
}
@ -1435,6 +1493,7 @@ function StatusLine(props: {
m.done ? 'Click to un-tick'
: isPaymentStep(m.key) ? 'Click to link a payment'
: isCredsStep(m.key) ? 'Click to set account credentials'
: isDocumentStep(m.key) ? 'Click to link the quotation / proforma'
: 'Click to mark done (today)'
}
onClick={() => onDotClick(m)}
@ -1550,6 +1609,55 @@ function PaymentPickerDialog(props: {
)
}
/**
* Quotation-step document picker (D38 follow-up): ticking the "Quotation sent" status
* step opens this instead of stamping a bare date it links the quotation/proforma that
* was actually sent (`setMilestone(..., documentId)`) so the status line and the document
* register agree. Mirrors `PaymentPickerDialog`'s structure/props exactly, reading the
* client's already-loaded ledger (no extra fetch); "Create new document" defers to the
* composer rather than inventing a document here.
*/
function DocumentPickerDialog(props: {
open: boolean; onClose: () => void; stepLabel: string
docs: Doc[]; saving: boolean; error?: string
onPick: (documentId: string) => void; onCreateNew: () => void
}) {
return (
<Dialog
open={props.open}
onClose={() => { if (!props.saving) props.onClose() }}
title={`Link a document — ${props.stepLabel}`}
size="sm"
footer={<Button onClick={props.onClose} disabled={props.saving}>Cancel</Button>}
>
{props.docs.length === 0
? <EmptyState>No quotation or proforma on record for this client yet.</EmptyState>
: (
<DataTable
columns={[
{ key: 'no', label: 'No', mono: true }, { key: 'type', label: 'Type' },
{ key: 'date', label: 'Date' }, { key: 'payable', label: 'Payable', numeric: true },
{ key: 'status', label: 'Status' }, { key: 'act', label: '' },
]}
rows={props.docs.map((d) => ({
no: d.docNo ?? 'draft',
type: <Badge tone={DOC_TYPE_TONE[d.docType]}>{DOC_TYPE_LABEL[d.docType]}</Badge>,
date: d.docDate,
payable: inr(d.payablePaise),
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
act: <Button tone="primary" disabled={props.saving} onClick={() => props.onPick(d.id)}>Use</Button>,
}))}
/>
)}
<Toolbar>
<span style={{ flex: 1 }} />
<Button disabled={props.saving} onClick={props.onCreateNew}>Create new document</Button>
</Toolbar>
{props.error !== undefined && <Notice tone="err">{props.error}</Notice>}
</Dialog>
)
}
/**
* Account-creation credentials dialog (D38 follow-up): ticking the "Account creation &
* registration" status-line step opens this instead of a bare toggle mirrors

@ -5,9 +5,9 @@ import {
FormField, FormGrid, Notice, PageHeader, Skeleton, Toolbar, useToast,
} from '@sims/ui'
import {
createTicket, getBranches, getClients, getEmployees, getModules, getTickets, patchTicket, staffId,
TICKET_STATUSES,
type Branch, type Client, type Employee, type Ticket, type TicketStatus,
createTicket, getBranches, getClients, getEmployees, getModules, getTicketTypes, getTickets, patchTicket, staffId,
TICKET_STATUSES, TICKET_TYPE_FALLBACK,
type Branch, type Client, type Employee, type Ticket, type TicketStatus, type TicketType,
} from '../api'
import { useData, useDebounced } from './Clients'
@ -25,6 +25,14 @@ export const TICKET_TONE: Record<TicketStatus, 'ok' | 'warn' | 'err' | 'accent'
open: 'accent', in_progress: 'warn', waiting: undefined, closed: 'ok', dropped: 'err',
}
/** Classification key label, resolved against the setting-backed list (config over
* code); an unknown key shows verbatim rather than disappearing. */
export function ticketTypeLabel(types: TicketType[], key: string): string {
const k = key ?? ''
if (k === '') return '—'
return types.find((t) => t.key === k)?.label ?? k
}
/** Whole days between an opened-on date (YYYY-MM-DD) and today. */
function ageDays(openedOn: string): number {
const opened = Date.parse(`${openedOn}T00:00:00Z`)
@ -124,32 +132,44 @@ export function TicketAssignSelect(props: {
* 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).
* from history via the list response). The Type select is the classification
* (`ticket.types` setting row, code fallback while it loads); `initialModuleCode`
* pre-fills the module so Client 360's per-module "Raise ticket" lands here ready.
*/
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 }
/** Pre-filled module code (Client 360 per-module "Raise ticket for this module"). */
initialModuleCode?: 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 [f, setF] = useState({ branchId: '', moduleCode: '', kind: '', type: '', description: '', onlineOffline: '' })
const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false)
// Live catalog feeds the suggestions — future modules appear with zero code changes.
const catalog = useData(getModules, [])
// Classification list is a DB setting; the code fallback only covers the load window.
const types = useData(getTicketTypes, [])
const typeOptions = types.data ?? TICKET_TYPE_FALLBACK
// Blank state means "whatever the list offers first" — never a hardcoded key.
const typeValue = f.type !== '' ? f.type : (typeOptions[0]?.key ?? '')
useEffect(() => {
if (!props.open) return
setError(undefined)
setQ(''); setHits([]); setPick(props.initialClient)
setF({ branchId: '', moduleCode: '', kind: '', description: '', onlineOffline: '' })
setF({
branchId: '', moduleCode: props.initialModuleCode ?? '', kind: '', type: '',
description: '', onlineOffline: '',
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.open, props.initialClient?.id])
}, [props.open, props.initialClient?.id, props.initialModuleCode])
useEffect(() => {
if (pick === undefined) { setBranches([]); return }
@ -174,6 +194,7 @@ export function NewTicketDialog(props: {
...(f.branchId !== '' ? { branchId: f.branchId } : {}),
...(f.moduleCode.trim() !== '' ? { moduleCode: f.moduleCode.trim() } : {}),
...(f.kind.trim() !== '' ? { kind: f.kind.trim() } : {}),
...(typeValue !== '' ? { type: typeValue } : {}),
...(f.description.trim() !== '' ? { description: f.description.trim() } : {}),
...(f.onlineOffline !== '' ? { onlineOffline: f.onlineOffline } : {}),
})
@ -234,6 +255,11 @@ export function NewTicketDialog(props: {
value={f.moduleCode} onChange={set('moduleCode')}
/>
</FormField>
<FormField label="Type">
<select className="wf" value={typeValue} onChange={set('type')}>
{typeOptions.map((t) => <option key={t.key} value={t.key}>{t.label}</option>)}
</select>
</FormField>
<FormField label="Kind">
<input
className="wf" list="hq-ticket-kinds" placeholder="e.g. NEW WORK"
@ -273,6 +299,7 @@ export function NewTicketDialog(props: {
export function Tickets() {
const [status, setStatus] = useState('open')
const [scope, setScope] = useState<'all' | 'mine'>('all')
const [type, setType] = useState('all')
const [q, setQ] = useState('')
const [page, setPage] = useState(1)
const [err, setErr] = useState('')
@ -280,6 +307,9 @@ export function Tickets() {
const me = staffId()
const nav = useNavigate()
const employees = useData(() => getEmployees(), [])
// Classification chips come from the `ticket.types` setting (config over code).
const types = useData(getTicketTypes, [])
const typeOptions = types.data ?? TICKET_TYPE_FALLBACK
// Debounced so typing in the search box doesn't fire a server request per keystroke.
const qDebounced = useDebounced(q)
@ -287,10 +317,11 @@ export function Tickets() {
() => getTickets({
...(status === 'overdue' ? { overdue: true } : status !== 'all' ? { status: status as TicketStatus } : {}),
...(scope === 'mine' ? { mine: true } : {}),
...(type !== 'all' ? { type } : {}),
...(qDebounced !== '' ? { q: qDebounced } : {}),
page, pageSize: PAGE_SIZE,
}),
[status, scope, qDebounced, page],
[status, scope, type, qDebounced, page],
)
const data = list.data
@ -320,6 +351,14 @@ export function Tickets() {
active={status}
onChange={(k) => { setStatus(k); setPage(1) }}
/>
<FilterChips
chips={[
{ key: 'all', label: 'All types' },
...typeOptions.map((t) => ({ key: t.key, label: t.label })),
]}
active={type}
onChange={(k) => { setType(k); setPage(1) }}
/>
{me !== '' && (
<FilterChips
chips={[{ key: 'all', label: 'All' }, { key: 'mine', label: 'Mine' }]}
@ -353,6 +392,7 @@ export function Tickets() {
{ key: 'client', label: 'Client' },
{ key: 'branch', label: 'Branch' },
{ key: 'module', label: 'Module', mono: true },
{ key: 'type', label: 'Type' },
{ key: 'kind', label: 'Kind' },
{ key: 'desc', label: 'Description' },
{ key: 'assigned', label: 'Assigned' },
@ -364,7 +404,12 @@ export function Tickets() {
age: <TicketAge openedOn={t.openedOn} status={t.status} slaDays={data?.slaDays ?? 7} />,
client: <Link to={`/clients/${t.clientId}`}>{t.clientName}</Link>,
branch: t.branchName ?? '—',
module: t.moduleCode ?? '—',
// The module cell jumps straight to that client's Modules tab, where the
// project's status line, service data and documents all live.
module: t.moduleCode !== null && t.moduleCode !== ''
? <Link to={`/clients/${t.clientId}?tab=modules`}>{t.moduleCode}</Link>
: '—',
type: ticketTypeLabel(typeOptions, t.type),
kind: t.kind !== '' ? t.kind : '—',
desc: <TicketDescription text={t.description} />,
assigned: employees.data !== undefined

Loading…
Cancel
Save