diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 3db1a75..fdb0852 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -378,23 +378,29 @@ export const revealModuleSecret = (id: string, key: string): Promise => /** 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 => 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 => 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 => + 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 => { 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(`/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 => 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 => apiFetch<{ ticket: Ticket }>(`/tickets/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.ticket) diff --git a/apps/hq-web/src/app.css b/apps/hq-web/src/app.css index d4349dc..be54391 100644 --- a/apps/hq-web/src/app.css +++ b/apps/hq-web/src/app.css @@ -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); } diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 535fd5d..c9e78b5 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -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() + // 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() 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() { {moduleName(cm.moduleId)} {cm.status} {KIND_LABEL[cm.kind]} - e.stopPropagation()}> + e.stopPropagation()} style={{ display: 'inline-flex', gap: 6 }}> cms.reload()} onError={toast.err} /> + {/* Support calls arrive per module — raise one without leaving the block. */} +
@@ -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 }) }} /> - + {tickets.data !== undefined && {tickets.data.total} total} @@ -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 !== '' + ? {t.moduleCode} + : '—', kind: t.kind !== '' ? t.kind : '—', desc: , assigned: t.assignedName ?? '—', @@ -383,13 +400,6 @@ export function ClientDetail() { )} )} - 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 && ( + 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. */} + 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 && ( 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 ( + { if (!props.saving) props.onClose() }} + title={`Link a document — ${props.stepLabel}`} + size="sm" + footer={} + > + {props.docs.length === 0 + ? No quotation or proforma on record for this client yet. + : ( + ({ + no: d.docNo ?? 'draft', + type: {DOC_TYPE_LABEL[d.docType]}, + date: d.docDate, + payable: inr(d.payablePaise), + status: {d.status}, + act: , + }))} + /> + )} + + + + + {props.error !== undefined && {props.error}} + + ) +} + /** * Account-creation credentials dialog (D38 follow-up): ticking the "Account creation & * registration" status-line step opens this instead of a bare toggle — mirrors diff --git a/apps/hq-web/src/pages/Tickets.tsx b/apps/hq-web/src/pages/Tickets.tsx index 2080abf..69dae20 100644 --- a/apps/hq-web/src/pages/Tickets.tsx +++ b/apps/hq-web/src/pages/Tickets.tsx @@ -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 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([]) 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 [f, setF] = useState({ branchId: '', moduleCode: '', kind: '', type: '', description: '', onlineOffline: '' }) const [error, setError] = useState() 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')} /> + + + ('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) }} /> + ({ key: t.key, label: t.label })), + ]} + active={type} + onChange={(k) => { setType(k); setPage(1) }} + /> {me !== '' && ( , client: {t.clientName}, 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 !== '' + ? {t.moduleCode} + : '—', + type: ticketTypeLabel(typeOptions, t.type), kind: t.kind !== '' ? t.kind : '—', desc: , assigned: employees.data !== undefined