feat(d28): #3 ticket desk — SLA aging + overdue filter + bill-from-ticket

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=<id>) — 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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 3 days ago
parent e83d70a4ff
commit 650cde43a2

@ -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<string, number>; 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<TicketsPage> => {
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))

@ -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=<id> 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<Record<string, ModulePrice[]>>({})
const loadPrices = (moduleId: string): Promise<ModulePrice[]> => {
const cached = pricesByModule[moduleId]

@ -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<TicketStatus, 'ok' | 'warn' | 'err' | 'accent'
open: 'accent', in_progress: 'warn', waiting: undefined, closed: 'ok', dropped: 'err',
}
/** 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`)
if (Number.isNaN(opened)) return 0
return Math.max(0, Math.floor((Date.now() - opened) / 86_400_000))
}
/** Age of a ticket, red when a still-open ticket has aged past the SLA window. */
export function TicketAge(props: { openedOn: string; status: TicketStatus; slaDays: number }) {
const live = props.status !== 'closed' && props.status !== 'dropped'
const age = ageDays(props.openedOn)
const overdue = live && age > props.slaDays
return (
<span style={{ fontWeight: overdue ? 700 : 400, color: overdue ? 'var(--err)' : 'var(--text-dim)' }}>
{age}d{overdue ? ' ⚠' : ''}
</span>
)
}
/** 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() {
<FilterChips
chips={[
{ key: 'all', label: 'All', count: allCount },
{ key: 'overdue', label: 'Overdue', count: data?.overdueCount ?? 0 },
...TICKET_STATUSES.map((s) => ({
key: s, label: TICKET_STATUS_LABEL[s], count: counts?.[s] ?? 0,
})),
@ -328,6 +349,7 @@ export function Tickets() {
<DataTable
columns={[
{ key: 'opened', label: 'Opened' },
{ key: 'age', label: 'Age' },
{ key: 'client', label: 'Client' },
{ key: 'branch', label: 'Branch' },
{ key: 'module', label: 'Module', mono: true },
@ -339,6 +361,7 @@ export function Tickets() {
]}
rows={rows.map((t) => ({
opened: t.openedOn,
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 ?? '—',
@ -348,7 +371,12 @@ export function Tickets() {
? <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} />,
act: (
<span style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', alignItems: 'center' }}>
<Button onClick={() => nav(`/documents/new?client=${t.clientId}`)}>Bill</Button>
<TicketActions t={t} onDone={list.reload} onError={setErr} />
</span>
),
}))}
/>
<Toolbar>

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

@ -60,6 +60,8 @@ export async function getTicket(db: DB, id: string): Promise<Ticket | null> {
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<Record<string, number>> {
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<number> {
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<string[]> {
const rows = await db.all<{ kind: string }>(

@ -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)
})
})
Loading…
Cancel
Save