From 650cde43a222fc3cf4850b088d19c04094499ded Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 00:29:56 +0530 Subject: [PATCH] =?UTF-8?q?feat(d28):=20#3=20ticket=20desk=20=E2=80=94=20S?= =?UTF-8?q?LA=20aging=20+=20overdue=20filter=20+=20bill-from-ticket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Age column on the workbench (days since opened); a still-open ticket aged past ticket.sla_days (dated setting, default 7) shows red with a warning. New 'Overdue' filter chip with an honest server-side count (still-open AND opened on/before the SLA cutoff). Each row gains a 'Bill' action that opens New Document with the client pre-selected (/documents/new?client=) — the light ticket->invoice path, staff add the lines. Kept deliberately un-bulky: no kanban, reuses the existing table. Test locks the overdue filter + count. typecheck + full suite (403) green. Co-Authored-By: Claude Fable 5 --- apps/hq-web/src/api.ts | 5 +++- apps/hq-web/src/pages/NewDocument.tsx | 11 +++++++-- apps/hq-web/src/pages/Tickets.tsx | 34 ++++++++++++++++++++++++--- apps/hq/src/api.ts | 11 +++++++-- apps/hq/src/repos-tickets.ts | 14 +++++++++++ apps/hq/test/ticket-sla.test.ts | 26 ++++++++++++++++++++ 6 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 apps/hq/test/ticket-sla.test.ts diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 38e0692..a8f8618 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -373,13 +373,15 @@ 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[] + /** SLA window (days) + count of still-open tickets past it — drives the Age tint + Overdue chip. */ + slaDays: number; overdueCount: number } /** 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 + overdue?: boolean; q?: string; page?: number; pageSize?: number } = {}, ): Promise => { const p = new URLSearchParams() @@ -387,6 +389,7 @@ export const getTickets = ( 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.overdue === true) p.set('overdue', '1') 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)) diff --git a/apps/hq-web/src/pages/NewDocument.tsx b/apps/hq-web/src/pages/NewDocument.tsx index b100520..9191b38 100644 --- a/apps/hq-web/src/pages/NewDocument.tsx +++ b/apps/hq-web/src/pages/NewDocument.tsx @@ -1,9 +1,9 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useSearchParams } from 'react-router-dom' import { formatINR, fromRupees } from '@sims/domain' import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui' import { - createDocument, getBillingSettings, getClients, getModules, getPrices, previewDocument, + createDocument, getBillingSettings, getClient, getClients, getModules, getPrices, previewDocument, KIND_LABEL, type Client, type DocTotals, type Kind, type ModulePrice, } from '../api' import { LivePreview } from '../components/LivePreview' @@ -98,6 +98,13 @@ export function NewDocument() { return () => clearTimeout(t) }, [q, client]) + // Deep link (e.g. "Bill" from a ticket): /documents/new?client= lands with the client chosen. + const [params] = useSearchParams() + useEffect(() => { + const cid = params.get('client') + if (cid !== null && cid !== '') getClient(cid).then(setClient).catch(() => { /* leave the picker empty */ }) + }, []) // eslint-disable-line react-hooks/exhaustive-deps -- read the query once on mount + const [pricesByModule, setPricesByModule] = useState>({}) const loadPrices = (moduleId: string): Promise => { const cached = pricesByModule[moduleId] diff --git a/apps/hq-web/src/pages/Tickets.tsx b/apps/hq-web/src/pages/Tickets.tsx index f1c2d43..2080abf 100644 --- a/apps/hq-web/src/pages/Tickets.tsx +++ b/apps/hq-web/src/pages/Tickets.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Link } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { Badge, Button, ConfirmDialog, DataTable, Dialog, EmptyState, ErrorState, FilterChips, FormField, FormGrid, Notice, PageHeader, Skeleton, Toolbar, useToast, @@ -25,6 +25,25 @@ export const TICKET_TONE: Record props.slaDays + return ( + + {age}d{overdue ? ' ⚠' : ''} + + ) +} + /** Description cell: truncated with the full text on hover (title). */ export function TicketDescription(props: { text: string }) { if (props.text === '') return <>— @@ -259,13 +278,14 @@ export function Tickets() { const [err, setErr] = useState('') const [creating, setCreating] = useState(false) const me = staffId() + const nav = useNavigate() const employees = useData(() => getEmployees(), []) // Debounced so typing in the search box doesn't fire a server request per keystroke. const qDebounced = useDebounced(q) const list = useData( () => getTickets({ - ...(status !== 'all' ? { status: status as TicketStatus } : {}), + ...(status === 'overdue' ? { overdue: true } : status !== 'all' ? { status: status as TicketStatus } : {}), ...(scope === 'mine' ? { mine: true } : {}), ...(qDebounced !== '' ? { q: qDebounced } : {}), page, pageSize: PAGE_SIZE, @@ -292,6 +312,7 @@ export function Tickets() { ({ key: s, label: TICKET_STATUS_LABEL[s], count: counts?.[s] ?? 0, })), @@ -328,6 +349,7 @@ export function Tickets() { ({ opened: t.openedOn, + age: , client: {t.clientName}, branch: t.branchName ?? '—', module: t.moduleCode ?? '—', @@ -348,7 +371,12 @@ export function Tickets() { ? : (t.assignedName ?? '—'), status: {TICKET_STATUS_LABEL[t.status]}, - act: , + act: ( + + + + + ), }))} /> diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 8d7a925..e6193d9 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -21,7 +21,7 @@ import { } from './repos-modules' import { createBranch, listBranches, updateBranch } from './repos-branches' import { - createTicket, getTicket, listTickets, ticketCounts, ticketKinds, updateTicket, + createTicket, getTicket, listTickets, ticketCounts, ticketKinds, overdueCount, updateTicket, TICKET_STATUSES, type CreateTicketInput, type TicketPatch, type TicketStatus, } from './repos-tickets' import { @@ -763,6 +763,9 @@ export function apiRouter( const q = req.query const status = typeof q['status'] === 'string' && (TICKET_STATUSES as readonly string[]).includes(q['status']) ? q['status'] as TicketStatus : undefined + // SLA: a still-open ticket older than ticket.sla_days is "overdue" (dated setting, config over code). + const slaDays = await getNumberSetting(db, 'ticket.sla_days', 7) + const cutoff = new Date(Date.now() - slaDays * 86_400_000).toISOString().slice(0, 10) const out = await listTickets(db, { ...(status !== undefined ? { status } : {}), ...(typeof q['clientId'] === 'string' && q['clientId'] !== '' ? { clientId: q['clientId'] } : {}), @@ -770,10 +773,14 @@ export function apiRouter( ? { assignedTo: staffId(res) } : typeof q['assignedTo'] === 'string' && q['assignedTo'] !== '' ? { assignedTo: q['assignedTo'] } : {}), ...(typeof q['module'] === 'string' && q['module'] !== '' ? { moduleCode: q['module'] } : {}), + ...(q['overdue'] === '1' ? { overdueBefore: cutoff } : {}), ...(typeof q['q'] === 'string' && q['q'] !== '' ? { q: q['q'] } : {}), page: Number(q['page'] ?? 1), pageSize: Number(q['pageSize'] ?? 50), }) - res.json({ ok: true, ...out, counts: await ticketCounts(db), kinds: await ticketKinds(db) }) + res.json({ + ok: true, ...out, counts: await ticketCounts(db), kinds: await ticketKinds(db), + slaDays, overdueCount: await overdueCount(db, cutoff), + }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } diff --git a/apps/hq/src/repos-tickets.ts b/apps/hq/src/repos-tickets.ts index 2186b80..a26b4c5 100644 --- a/apps/hq/src/repos-tickets.ts +++ b/apps/hq/src/repos-tickets.ts @@ -60,6 +60,8 @@ export async function getTicket(db: DB, id: string): Promise { export interface ListTicketsOpts { status?: TicketStatus; clientId?: string; assignedTo?: string; moduleCode?: string + /** Overdue = still-open (open/in_progress/waiting) and opened on or before this date. */ + overdueBefore?: string q?: string; page?: number; pageSize?: number } @@ -73,6 +75,10 @@ export async function listTickets( if (opts.clientId !== undefined) { where += ` AND t.client_id=?`; args.push(opts.clientId) } if (opts.assignedTo !== undefined) { where += ` AND t.assigned_to=?`; args.push(opts.assignedTo) } if (opts.moduleCode !== undefined) { where += ` AND t.module_code=?`; args.push(opts.moduleCode) } + if (opts.overdueBefore !== undefined) { + where += ` AND t.status IN ('open','in_progress','waiting') AND t.opened_on<=?` + args.push(opts.overdueBefore) + } if (opts.q !== undefined && opts.q !== '') { where += ` AND (LOWER(t.description) LIKE LOWER(?) OR LOWER(t.kind) LIKE LOWER(?) OR LOWER(c.name) LIKE LOWER(?))` const like = `%${opts.q}%` @@ -98,6 +104,14 @@ export async function ticketCounts(db: DB): Promise> { return Object.fromEntries(rows.map((r) => [r.status, r.n])) } +/** Count of still-open tickets past the SLA cutoff (opened on/before it) — for the Overdue chip. */ +export async function overdueCount(db: DB, cutoff: string): Promise { + return (await db.get<{ n: number }>( + `SELECT COUNT(*) AS n FROM ticket + WHERE status IN ('open','in_progress','waiting') AND opened_on<=?`, cutoff, + ))!.n +} + /** Distinct kinds seen so far — the UI suggestion list (config over code: history, not constants). */ export async function ticketKinds(db: DB): Promise { const rows = await db.all<{ kind: string }>( diff --git a/apps/hq/test/ticket-sla.test.ts b/apps/hq/test/ticket-sla.test.ts new file mode 100644 index 0000000..1dd4053 --- /dev/null +++ b/apps/hq/test/ticket-sla.test.ts @@ -0,0 +1,26 @@ +// apps/hq/test/ticket-sla.test.ts — D28: ticket SLA (overdue = still-open, aged past cutoff). +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createTicket, updateTicket, listTickets, overdueCount } from '../src/repos-tickets' + +const iso = (daysAgo: number) => new Date(Date.now() - daysAgo * 86_400_000).toISOString().slice(0, 10) + +describe('ticket SLA overdue filter (D28)', () => { + it('counts/lists only still-open tickets opened on or before the cutoff', async () => { + const db = openDb(':memory:'); await seedIfEmpty(db) + const c = await createClient(db, 'u1', { name: 'A', stateCode: '32' }) + const old = await createTicket(db, 'u1', { clientId: c.id, description: 'old open', openedOn: iso(20) }) + await createTicket(db, 'u1', { clientId: c.id, description: 'fresh open', openedOn: iso(2) }) + const oldClosed = await createTicket(db, 'u1', { clientId: c.id, description: 'old but closed', openedOn: iso(30) }) + await updateTicket(db, 'u1', oldClosed.id, { status: 'closed' }) + + const cutoff = iso(7) // SLA window = 7 days + expect(await overdueCount(db, cutoff)).toBe(1) // only the 20-day-old open one + + const page = await listTickets(db, { overdueBefore: cutoff }) + expect(page.total).toBe(1) + expect(page.tickets[0]!.id).toBe(old.id) + }) +})