feat(tickets): deliver canonical operational lifecycle

This commit is contained in:
2026-08-22 11:41:45 +02:00
parent b4bb52d8b8
commit 7cbf3bed96
25 changed files with 4742 additions and 106 deletions
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@govoplan/tickets-webui",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/tickets.css": "./src/styles/tickets.css"
},
"scripts": {
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.30",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/tickets/TicketsPage.tsx", "utf8");
const styles = fs.readFileSync("src/styles/tickets.css", "utf8");
assert.ok(page.includes("WorkspaceActionBar"), "Tickets uses the semantic shared action bar");
assert.ok(page.includes("refreshable"), "The refreshable Tickets workspace exposes Reload");
assert.ok(page.includes("DocumentationHelpLink"), "Tickets exposes contextual documentation");
assert.ok(page.includes("PageScrollViewport"), "Tickets owns bounded queue and detail scrolling");
assert.ok(page.includes("<Dialog"), "Ticket mutations use shared focus-contained dialogs");
assert.ok(page.includes("FieldLabel"), "Ticket fields use the shared label and help contract");
assert.ok(page.includes("StatusBadge"), "Ticket state is not conveyed by color alone");
assert.ok(!page.includes("window.alert("), "Tickets must not use browser alerts");
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Tickets uses semantic interactive elements");
assert.ok(styles.includes("@media (max-width: 760px)"), "Tickets retains a responsive queue-detail layout");
console.log("Tickets interface pattern contract passed.");
+155
View File
@@ -0,0 +1,155 @@
import {
apiFetch,
apiPath,
type ApiSettings
} from "@govoplan/core-webui";
export type TicketSubject = {
kind: string;
id: string;
label?: string | null;
role?: string | null;
};
export type TicketLink = {
link_id: string;
kind: string;
owner_module: string;
resource_type: string;
resource_id: string;
relation: string;
label?: string | null;
url?: string | null;
metadata: Record<string, unknown>;
};
export type TicketRecord = {
tenant_id: string;
ticket_id: string;
ticket_number: string;
revision: number;
ticket_type: "request" | "incident" | "problem" | "report";
priority: "low" | "normal" | "high" | "urgent";
status: string;
title: string;
description: string;
visibility: "tenant" | "restricted";
queue_ref?: string | null;
assignee?: TicketSubject | null;
reporter?: TicketSubject | null;
requester?: TicketSubject | null;
participants: TicketSubject[];
links: TicketLink[];
service_target_at?: string | null;
received_at: string;
recorded_at: string;
resolved_at?: string | null;
resolution_summary?: string | null;
deleted_at?: string | null;
change_reason: string;
metadata: Record<string, unknown>;
};
export type TicketListResponse = {
tickets: TicketRecord[];
total: number;
offset: number;
limit: number;
};
export type TicketAvailability = {
routing: { available: boolean; provider?: string | null };
case_escalation: { available: boolean; provider?: string | null };
modules: Record<string, boolean>;
consequences: Record<string, string>;
};
export function listTickets(
settings: ApiSettings,
options: { statuses?: string[]; priorities?: string[]; ticketTypes?: string[]; queueRef?: string; query?: string; limit?: number },
signal?: AbortSignal
): Promise<TicketListResponse> {
return apiFetch(settings, apiPath("/api/v1/tickets", {
status: options.statuses,
priority: options.priorities,
ticket_type: options.ticketTypes,
queue_ref: options.queueRef,
query: options.query,
limit: options.limit
}), { signal });
}
export function getTicketAvailability(settings: ApiSettings, signal?: AbortSignal): Promise<TicketAvailability> {
return apiFetch(settings, "/api/v1/tickets/availability", { signal });
}
export function createTicket(settings: ApiSettings, record: TicketRecord): Promise<TicketRecord> {
return apiFetch(settings, "/api/v1/tickets", {
method: "POST",
body: JSON.stringify({ record, idempotency_key: crypto.randomUUID() })
});
}
function mutation(record: TicketRecord, changeReason: string) {
return {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: changeReason,
idempotency_key: crypto.randomUUID()
};
}
export function triageTicket(settings: ApiSettings, record: TicketRecord, changes: Record<string, unknown>, changeReason: string): Promise<TicketRecord> {
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/triage`, {
method: "PATCH",
body: JSON.stringify({ ...mutation(record, changeReason), changes })
});
}
export function assignTicket(settings: ApiSettings, record: TicketRecord, assignee: TicketSubject | null, changeReason: string): Promise<TicketRecord> {
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/assignment`, {
method: "POST",
body: JSON.stringify({ ...mutation(record, changeReason), assignee })
});
}
export function resolveTicket(
settings: ApiSettings,
record: TicketRecord,
targetStatus: string,
resolutionSummary: string | null,
changeReason: string
): Promise<TicketRecord> {
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/resolution`, {
method: "POST",
body: JSON.stringify({ ...mutation(record, changeReason), target_status: targetStatus, resolution_summary: resolutionSummary })
});
}
export function addTicketComment(settings: ApiSettings, record: TicketRecord, body: string, visibility: "internal" | "external") {
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/comments`, {
method: "POST",
body: JSON.stringify({
expected_revision: record.revision,
comment_id: crypto.randomUUID(),
body,
visibility,
recorded_at: new Date().toISOString(),
idempotency_key: crypto.randomUUID()
})
}) as Promise<{ ticket: TicketRecord; comment: Record<string, unknown> }>;
}
export function escalateTicket(settings: ApiSettings, record: TicketRecord, caseTypeKey: string, handoffNote: string) {
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/case-escalations`, {
method: "POST",
body: JSON.stringify({
expected_revision: record.revision,
case_type_key: caseTypeKey,
handoff_note: handoffNote || null,
occurred_at: new Date().toISOString(),
idempotency_key: crypto.randomUUID()
})
}) as Promise<{ ticket: TicketRecord; escalation: Record<string, unknown> }>;
}
+595
View File
@@ -0,0 +1,595 @@
import {
MessageSquarePlus,
Pencil,
Plus,
Search,
Send,
TicketCheck,
UserRoundCheck
} from "lucide-react";
import {
useEffect,
useMemo,
useState,
type FormEvent
} from "react";
import {
Button,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FieldLabel,
FilterBar,
FormLayout,
LoadingIndicator,
PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
WorkspaceActionBar,
WorkspaceFrame,
hasScope,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
addTicketComment,
assignTicket,
createTicket,
escalateTicket,
getTicketAvailability,
listTickets,
resolveTicket,
triageTicket,
type TicketAvailability,
type TicketRecord,
type TicketSubject
} from "../../api/tickets";
type EditorValues = {
ticketType: TicketRecord["ticket_type"];
priority: TicketRecord["priority"];
status: string;
title: string;
description: string;
visibility: TicketRecord["visibility"];
queueRef: string;
serviceTargetAt: string;
changeReason: string;
};
const STATES = ["new", "triaged", "in_progress", "waiting", "cancelled"];
export default function TicketsPage({ settings, auth }: PlatformRouteContext) {
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [tickets, setTickets] = useState<TicketRecord[]>([]);
const [selectedId, setSelectedId] = useState("");
const [total, setTotal] = useState(0);
const [availability, setAvailability] = useState<TicketAvailability | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [dialogError, setDialogError] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<TicketRecord | null>(null);
const [assignmentOpen, setAssignmentOpen] = useState(false);
const [resolutionOpen, setResolutionOpen] = useState(false);
const [escalationOpen, setEscalationOpen] = useState(false);
const [saving, setSaving] = useState(false);
const canReport = hasAny(auth, "tickets:ticket:report", "tickets:ticket:admin", "tickets:ticket:write");
const canTriage = hasAny(auth, "tickets:ticket:triage", "tickets:ticket:admin", "tickets:ticket:write");
const canAssign = hasAny(auth, "tickets:ticket:assign", "tickets:ticket:admin", "tickets:ticket:write");
const canResolve = hasAny(auth, "tickets:ticket:resolve", "tickets:ticket:admin", "tickets:ticket:write");
const canCreateCase = hasScope(auth, "cases:case:create");
function reload(signal?: AbortSignal) {
setLoading(true);
setError("");
return Promise.all([
listTickets(settings, {
statuses: statusFilter ? [statusFilter] : undefined,
query: submittedQuery,
limit: 200
}, signal),
getTicketAvailability(settings, signal)
]).
then(([result, integrationState]) => {
setTickets(result.tickets);
setTotal(result.total);
setAvailability(integrationState);
setSelectedId((current) => result.tickets.some((item) => item.ticket_id === current)
? current
: result.tickets[0]?.ticket_id ?? "");
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Tickets could not be loaded.");
}
}).
finally(() => setLoading(false));
}
useEffect(() => {
const controller = new AbortController();
void reload(controller.signal);
return () => controller.abort();
}, [settings, statusFilter, submittedQuery]);
const selected = useMemo(
() => tickets.find((item) => item.ticket_id === selectedId) ?? null,
[tickets, selectedId]
);
function submitSearch(event: FormEvent) {
event.preventDefault();
setSubmittedQuery(query.trim());
}
async function saveEditor(values: EditorValues) {
setSaving(true);
setDialogError("");
try {
let saved: TicketRecord;
if (editing) {
const changes: Record<string, unknown> = {
ticket_type: values.ticketType,
priority: values.priority,
title: values.title,
description: values.description,
visibility: values.visibility,
queue_ref: values.queueRef || null,
service_target_at: dateTimeValue(values.serviceTargetAt)
};
if (editing.status !== "resolved" && editing.status !== "closed") {
changes.status = values.status;
}
saved = await triageTicket(settings, editing, changes, values.changeReason);
} else {
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
const accountId = auth.user.account_id;
const now = new Date().toISOString();
const ticketId = crypto.randomUUID();
const subject: TicketSubject = {
kind: "account",
id: accountId,
label: auth.user.display_name ?? auth.user.email
};
saved = await createTicket(settings, {
tenant_id: tenantId,
ticket_id: ticketId,
ticket_number: ticketNumber(ticketId),
revision: 1,
ticket_type: values.ticketType,
priority: values.priority,
status: "new",
title: values.title,
description: values.description,
visibility: values.visibility,
queue_ref: values.queueRef || null,
assignee: null,
reporter: subject,
requester: subject,
participants: [],
links: [],
service_target_at: dateTimeValue(values.serviceTargetAt),
received_at: now,
recorded_at: now,
resolved_at: null,
resolution_summary: null,
deleted_at: null,
change_reason: values.changeReason,
metadata: {}
});
}
setEditorOpen(false);
setEditing(null);
await reload();
setSelectedId(saved.ticket_id);
} catch (reason) {
setDialogError(message(reason, "The ticket could not be saved."));
} finally {
setSaving(false);
}
}
async function saveAssignment(subject: TicketSubject | null, reason: string) {
if (!selected) return;
await runAction(async () => {
const saved = await assignTicket(settings, selected, subject, reason);
setAssignmentOpen(false);
return saved;
});
}
async function saveResolution(status: string, summary: string, reason: string) {
if (!selected) return;
await runAction(async () => {
const saved = await resolveTicket(settings, selected, status, summary || null, reason);
setResolutionOpen(false);
return saved;
});
}
async function saveEscalation(caseType: string, note: string) {
if (!selected) return;
await runAction(async () => {
const result = await escalateTicket(settings, selected, caseType, note);
setEscalationOpen(false);
return result.ticket;
});
}
async function saveComment(body: string, visibility: "internal" | "external") {
if (!selected) return;
await runAction(async () => (await addTicketComment(settings, selected, body, visibility)).ticket);
}
async function runAction(action: () => Promise<TicketRecord>) {
setSaving(true);
setDialogError("");
try {
const saved = await action();
await reload();
setSelectedId(saved.ticket_id);
} catch (reason) {
setDialogError(message(reason, "The ticket action could not be completed."));
} finally {
setSaving(false);
}
}
return (
<main className="tickets-page">
<WorkspaceFrame className="tickets-shell" label="Tickets workspace" interfaceId="tickets.route.workspace" helpContextId="tickets.route.workspace" helpModuleId="tickets">
<WorkspaceActionBar
scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void reload(), loading }}
contextActions={<>
<FilterBar as="form" surface="control" wrap="never" width="default" className="tickets-search" onSubmit={submitSearch}>
<Search size={17} aria-hidden="true" />
<input value={query} onChange={(event) => setQuery(event.target.value)} aria-label="Search tickets" placeholder="Search number, title, description, or queue" />
<Button type="submit" variant="primary">Search</Button>
</FilterBar>
<label className="tickets-status-filter">
<span>Status</span>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
<option value="">All current tickets</option>
{[...STATES, "resolved", "closed"].map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
</select>
</label>
<span className="tickets-count">{total} tickets</span>
</>}
helpAction={<DocumentationHelpLink reference={{ topicId: "tickets.operational-workflow", documentationType: "user" }} label="Open Tickets documentation" />}
createAction={canReport ?
<Button type="button" variant="primary" onClick={() => {
setEditing(null);
setDialogError("");
setEditorOpen(true);
}}><Plus size={16} aria-hidden="true" /> Report ticket</Button>
: undefined}
/>
{error && <DismissibleAlert tone="danger" onDismiss={() => setError("")}>{error}</DismissibleAlert>}
{dialogError && <DismissibleAlert tone="danger" resetKey={dialogError} onDismiss={() => setDialogError("")}>{dialogError}</DismissibleAlert>}
{availability && (!availability.routing.available || !availability.case_escalation.available) &&
<div className="tickets-availability" role="status">
{!availability.routing.available && <span>Automatic Helpdesk routing is unavailable; queue and target remain manual.</span>}
{!availability.case_escalation.available && <span>Cases escalation is unavailable; ticket resolution remains usable.</span>}
</div>
}
<div className="tickets-workspace">
<PageScrollViewport className="tickets-list-viewport">
{loading && <LoadingIndicator label="Loading tickets" />}
{!loading && tickets.length === 0 && <StatePanel size="compact" description="No matching tickets." />}
<SelectionList variant="navigation" label="Ticket queue">
{tickets.map((item) =>
<SelectionListItem key={item.ticket_id} selected={item.ticket_id === selectedId} onClick={() => setSelectedId(item.ticket_id)}>
<SelectionListItemContent
leading={<TicketCheck size={18} aria-hidden="true" />}
title={item.title}
description={`${item.ticket_number} · ${item.queue_ref || "No queue"} · ${formatDate(item.service_target_at)}`}
/>
<div className="ticket-list-state">
<StatusBadge status={priorityTone(item.priority)} label={humanize(item.priority)} />
<StatusBadge status={statusTone(item.status)} label={humanize(item.status)} />
</div>
</SelectionListItem>
)}
</SelectionList>
</PageScrollViewport>
<PageScrollViewport className="ticket-detail-viewport">
{selected ?
<TicketDetail
record={selected}
availability={availability}
canTriage={canTriage}
canAssign={canAssign}
canResolve={canResolve}
canCreateCase={canCreateCase}
saving={saving}
onEdit={() => {
setEditing(selected);
setDialogError("");
setEditorOpen(true);
}}
onAssign={() => setAssignmentOpen(true)}
onResolve={() => setResolutionOpen(true)}
onEscalate={() => setEscalationOpen(true)}
onComment={saveComment}
/> :
<StatePanel size="fill" title="Tickets" description="Select a ticket to inspect and continue its work." />
}
</PageScrollViewport>
</div>
</WorkspaceFrame>
<TicketEditorDialog open={editorOpen} record={editing} saving={saving} error={dialogError} onClose={() => setEditorOpen(false)} onSave={saveEditor} />
<AssignmentDialog open={assignmentOpen} record={selected} saving={saving} onClose={() => setAssignmentOpen(false)} onSave={saveAssignment} />
<ResolutionDialog open={resolutionOpen} record={selected} saving={saving} onClose={() => setResolutionOpen(false)} onSave={saveResolution} />
<EscalationDialog open={escalationOpen} record={selected} saving={saving} onClose={() => setEscalationOpen(false)} onSave={saveEscalation} />
</main>
);
}
function TicketDetail({ record, availability, canTriage, canAssign, canResolve, canCreateCase, saving, onEdit, onAssign, onResolve, onEscalate, onComment }: {
record: TicketRecord;
availability: TicketAvailability | null;
canTriage: boolean;
canAssign: boolean;
canResolve: boolean;
canCreateCase: boolean;
saving: boolean;
onEdit: () => void;
onAssign: () => void;
onResolve: () => void;
onEscalate: () => void;
onComment: (body: string, visibility: "internal" | "external") => Promise<void>;
}) {
const [comment, setComment] = useState("");
const [visibility, setVisibility] = useState<"internal" | "external">("internal");
return (
<article className="ticket-detail">
<header className="ticket-detail-header">
<div>
<span className="ticket-eyebrow">{record.ticket_number} · {humanize(record.ticket_type)}</span>
<h1>{record.title}</h1>
</div>
<div className="ticket-detail-badges">
<StatusBadge status={priorityTone(record.priority)} label={humanize(record.priority)} />
<StatusBadge status={statusTone(record.status)} label={humanize(record.status)} />
</div>
</header>
<div className="ticket-detail-actions" aria-label="Ticket actions">
{canTriage && <Button type="button" onClick={onEdit}><Pencil size={16} /> Triage</Button>}
{canAssign && <Button type="button" onClick={onAssign}><UserRoundCheck size={16} /> Assign</Button>}
{canResolve && <Button type="button" variant="primary" onClick={onResolve}><TicketCheck size={16} /> Advance</Button>}
{canTriage && canCreateCase && availability?.case_escalation.available && <Button type="button" onClick={onEscalate}><Send size={16} /> Escalate to Case</Button>}
</div>
<p className="ticket-description">{record.description}</p>
<div className="ticket-facts">
<Fact label="Queue" value={record.queue_ref || "Not selected"} />
<Fact label="Service target" value={formatDateTime(record.service_target_at)} />
<Fact label="Assignee" value={record.assignee?.label || record.assignee?.id || "Unassigned"} />
<Fact label="Revision" value={String(record.revision)} />
<Fact label="Reporter" value={record.reporter?.label || record.reporter?.id || "Not recorded"} />
<Fact label="Visibility" value={humanize(record.visibility)} />
<Fact label="Received" value={formatDateTime(record.received_at)} />
<Fact label="Resolved" value={formatDateTime(record.resolved_at)} />
</div>
{record.resolution_summary &&
<section className="ticket-section">
<h2>Resolution</h2>
<p>{record.resolution_summary}</p>
</section>
}
<section className="ticket-section">
<h2>References and attachments <span>{record.links.length}</span></h2>
{record.links.length === 0 ? <p className="ticket-muted">No typed references have been added.</p> :
<ul className="ticket-link-list">
{record.links.map((link) => <li key={link.link_id}>{link.url ? <a href={link.url}>{link.label || link.resource_id}</a> : <span>{link.label || link.resource_id}</span>}<small>{humanize(link.kind)} · {link.owner_module}</small></li>)}
</ul>
}
</section>
<section className="ticket-section">
<h2><MessageSquarePlus size={17} /> Add comment</h2>
<form className="ticket-comment-form" onSubmit={(event) => {
event.preventDefault();
const body = comment.trim();
if (!body) return;
void onComment(body, visibility).then(() => setComment(""));
}}>
<textarea value={comment} onChange={(event) => setComment(event.target.value)} rows={3} maxLength={20_000} aria-label="Ticket comment" placeholder="Record a factual follow-up" />
<select value={visibility} onChange={(event) => setVisibility(event.target.value as "internal" | "external")} aria-label="Comment visibility">
<option value="internal">Internal comment</option>
<option value="external">Reporter-visible comment</option>
</select>
<Button type="submit" disabled={saving || !comment.trim()}>Add comment</Button>
</form>
</section>
</article>
);
}
function TicketEditorDialog({ open, record, saving, error, onClose, onSave }: {
open: boolean;
record: TicketRecord | null;
saving: boolean;
error: string;
onClose: () => void;
onSave: (values: EditorValues) => Promise<void>;
}) {
const [values, setValues] = useState(() => editorValues(record));
useEffect(() => { if (open) setValues(editorValues(record)); }, [open, record]);
function set<K extends keyof EditorValues>(key: K, value: EditorValues[K]) {
setValues((current) => ({ ...current, [key]: value }));
}
return (
<Dialog open={open} title={record ? "Triage ticket" : "Report ticket"} onClose={onClose} closeDisabled={saving} className="ticket-editor-dialog" footer={<>
<Button type="button" onClick={onClose} disabled={saving}>Cancel</Button>
<Button type="submit" form="ticket-editor-form" variant="primary" disabled={saving}>{saving ? "Saving..." : "Save"}</Button>
</>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="ticket-editor-form" columns={2} gap="compact" collapseAt="narrow" className="ticket-editor-form" onSubmit={(event) => { event.preventDefault(); void onSave(values); }}>
<label><FieldLabel>Type</FieldLabel><select value={values.ticketType} onChange={(event) => set("ticketType", event.target.value as EditorValues["ticketType"])}><option value="request">Request</option><option value="incident">Incident</option><option value="problem">Problem</option><option value="report">Report</option></select></label>
<label><FieldLabel>Priority</FieldLabel><select value={values.priority} onChange={(event) => set("priority", event.target.value as EditorValues["priority"])}><option value="low">Low</option><option value="normal">Normal</option><option value="high">High</option><option value="urgent">Urgent</option></select></label>
{record && <label><FieldLabel>Status</FieldLabel><select value={values.status} disabled={record.status === "resolved" || record.status === "closed"} onChange={(event) => set("status", event.target.value)}>{!STATES.includes(values.status) && <option value={values.status}>{humanize(values.status)}</option>}{STATES.map((state) => <option key={state} value={state}>{humanize(state)}</option>)}</select></label>}
<label><FieldLabel>Visibility</FieldLabel><select value={values.visibility} onChange={(event) => set("visibility", event.target.value as EditorValues["visibility"])}><option value="restricted">Participants and queue staff</option><option value="tenant">Entire tenant</option></select></label>
<label className="wide"><FieldLabel>Title</FieldLabel><input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} /></label>
<label className="wide"><FieldLabel>Description</FieldLabel><textarea value={values.description} required rows={6} maxLength={40_000} onChange={(event) => set("description", event.target.value)} /></label>
<label><FieldLabel help="May be selected manually when Helpdesk routing is absent.">Queue</FieldLabel><input value={values.queueRef} maxLength={255} onChange={(event) => set("queueRef", event.target.value)} /></label>
<label><FieldLabel>Service target</FieldLabel><input type="datetime-local" value={values.serviceTargetAt} onChange={(event) => set("serviceTargetAt", event.target.value)} /></label>
<label className="wide"><FieldLabel help="Stored with immutable revision evidence.">Change reason</FieldLabel><input value={values.changeReason} required maxLength={1_000} onChange={(event) => set("changeReason", event.target.value)} /></label>
</FormLayout>
</Dialog>
);
}
function AssignmentDialog({ open, record, saving, onClose, onSave }: {
open: boolean;
record: TicketRecord | null;
saving: boolean;
onClose: () => void;
onSave: (subject: TicketSubject | null, reason: string) => Promise<void>;
}) {
const [kind, setKind] = useState("account");
const [id, setId] = useState("");
const [label, setLabel] = useState("");
const [reason, setReason] = useState("Assigned ticket for further work.");
useEffect(() => {
if (!open) return;
setKind(record?.assignee?.kind || "account");
setId(record?.assignee?.id || "");
setLabel(record?.assignee?.label || "");
}, [open, record]);
return <Dialog open={open} title="Assign ticket" onClose={onClose} closeDisabled={saving} footer={<><Button onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="ticket-assignment-form" variant="primary" disabled={saving}>Save assignment</Button></>}>
<FormLayout id="ticket-assignment-form" columns={1} gap="compact" onSubmit={(event) => { event.preventDefault(); void onSave(id.trim() ? { kind, id: id.trim(), label: label.trim() || null } : null, reason); }}>
<label><FieldLabel>Subject type</FieldLabel><select value={kind} onChange={(event) => setKind(event.target.value)}><option value="account">Account</option><option value="group">Group</option><option value="role">Role</option><option value="function">Function</option><option value="organization_unit">Organization unit</option></select></label>
<label><FieldLabel help="Leave empty to remove the current assignment.">Subject identifier</FieldLabel><input value={id} maxLength={255} onChange={(event) => setId(event.target.value)} /></label>
<label><FieldLabel>Display label</FieldLabel><input value={label} maxLength={500} onChange={(event) => setLabel(event.target.value)} /></label>
<label><FieldLabel>Change reason</FieldLabel><input value={reason} required maxLength={1_000} onChange={(event) => setReason(event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function ResolutionDialog({ open, record, saving, onClose, onSave }: {
open: boolean;
record: TicketRecord | null;
saving: boolean;
onClose: () => void;
onSave: (status: string, summary: string, reason: string) => Promise<void>;
}) {
const [status, setStatus] = useState("resolved");
const [summary, setSummary] = useState("");
const [reason, setReason] = useState("Advanced ticket lifecycle.");
useEffect(() => {
if (!open) return;
setStatus(record?.status === "resolved" || record?.status === "closed" ? "in_progress" : "resolved");
setSummary(record?.resolution_summary || "");
}, [open, record]);
const needsSummary = status === "resolved" || status === "closed";
return <Dialog open={open} title="Advance ticket" onClose={onClose} closeDisabled={saving} footer={<><Button onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="ticket-resolution-form" variant="primary" disabled={saving}>Apply</Button></>}>
<FormLayout id="ticket-resolution-form" columns={1} gap="compact" onSubmit={(event) => { event.preventDefault(); void onSave(status, summary, reason); }}>
<label><FieldLabel>Target state</FieldLabel><select value={status} onChange={(event) => setStatus(event.target.value)}><option value="in_progress">In progress / reopen</option><option value="waiting">Waiting</option><option value="resolved">Resolved</option><option value="closed">Closed</option><option value="cancelled">Cancelled</option></select></label>
<label><FieldLabel help="Required when resolving or closing.">Resolution summary</FieldLabel><textarea value={summary} required={needsSummary} rows={5} maxLength={20_000} onChange={(event) => setSummary(event.target.value)} /></label>
<label><FieldLabel>Change reason</FieldLabel><input value={reason} required maxLength={1_000} onChange={(event) => setReason(event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function EscalationDialog({ open, record, saving, onClose, onSave }: {
open: boolean;
record: TicketRecord | null;
saving: boolean;
onClose: () => void;
onSave: (caseType: string, note: string) => Promise<void>;
}) {
const [caseType, setCaseType] = useState("");
const [note, setNote] = useState("");
useEffect(() => { if (open) { setCaseType(""); setNote(""); } }, [open, record]);
return <Dialog open={open} title="Escalate ticket to Case" onClose={onClose} closeDisabled={saving} footer={<><Button onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="ticket-escalation-form" variant="primary" disabled={saving}>Create linked Case</Button></>}>
<FormLayout id="ticket-escalation-form" columns={1} gap="compact" onSubmit={(event) => { event.preventDefault(); void onSave(caseType, note); }}>
<p className="ticket-muted">The Ticket remains the authoritative intake and service history. Cases owns the formal procedure and returns a stable link.</p>
<label><FieldLabel help="Must match an active type configured in Cases.">Case type key</FieldLabel><input value={caseType} required maxLength={120} onChange={(event) => setCaseType(event.target.value)} /></label>
<label><FieldLabel>Handoff note</FieldLabel><textarea value={note} rows={5} maxLength={10_000} onChange={(event) => setNote(event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function Fact({ label, value }: { label: string; value: string }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
function editorValues(record: TicketRecord | null): EditorValues {
return {
ticketType: record?.ticket_type ?? "request",
priority: record?.priority ?? "normal",
status: record?.status ?? "new",
title: record?.title ?? "",
description: record?.description ?? "",
visibility: record?.visibility ?? "restricted",
queueRef: record?.queue_ref ?? "",
serviceTargetAt: dateTimeInput(record?.service_target_at),
changeReason: record ? "Updated ticket triage information." : "Reported an operational request."
};
}
function hasAny(auth: PlatformRouteContext["auth"], ...scopes: string[]): boolean {
return scopes.some((scope) => hasScope(auth, scope));
}
function ticketNumber(id: string): string {
const date = new Date().toISOString().slice(0, 10).replaceAll("-", "");
return `TKT-${date}-${id.slice(0, 8).toUpperCase()}`;
}
function humanize(value: string): string {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function statusTone(status: string): string {
if (status === "resolved" || status === "closed") return "active";
if (status === "cancelled") return "inactive";
if (status === "waiting") return "warning";
return "pending";
}
function priorityTone(priority: string): string {
if (priority === "urgent") return "danger";
if (priority === "high") return "warning";
return "inactive";
}
function formatDate(value?: string | null): string {
if (!value) return "No target";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(value));
}
function formatDateTime(value?: string | null): string {
if (!value) return "Not set";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function dateTimeInput(value?: string | null): string {
if (!value) return "";
const date = new Date(value);
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
}
function dateTimeValue(value: string): string | null {
return value ? new Date(value).toISOString() : null;
}
function message(reason: unknown, fallback: string): string {
return reason instanceof Error ? reason.message : fallback;
}
+1
View File
@@ -0,0 +1 @@
export { ticketsModule as default, ticketsModule } from "./module";
+49
View File
@@ -0,0 +1,49 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/tickets.css";
const TicketsPage = lazy(() => import("./features/tickets/TicketsPage"));
export const ticketsModule: PlatformWebModule = {
id: "tickets",
label: "Tickets",
version: "0.1.20",
optionalDependencies: [
"cases",
"helpdesk",
"projects",
"wiki",
"files",
"search"
],
routes: [
{
path: "/tickets",
anyOf: ["tickets:ticket:read"],
order: 22,
surfaceId: "tickets.route.workspace",
render: (context) => createElement(TicketsPage, context)
}
],
navItems: [
{
to: "/tickets",
label: "Tickets",
iconName: "ticket-check",
anyOf: ["tickets:ticket:read"],
order: 22,
surfaceId: "tickets.navigation"
}
],
viewSurfaces: [
{ id: "tickets.navigation", moduleId: "tickets", kind: "navigation", label: "Tickets navigation", order: 10 },
{ id: "tickets.route.workspace", moduleId: "tickets", kind: "route", label: "Tickets workspace", order: 20 },
{ id: "tickets.page.queue", moduleId: "tickets", kind: "section", label: "Ticket queue", parentId: "tickets.route.workspace", order: 30 },
{ id: "tickets.page.detail", moduleId: "tickets", kind: "section", label: "Ticket details", parentId: "tickets.route.workspace", order: 40 },
{ id: "tickets.action.report", moduleId: "tickets", kind: "action", label: "Report ticket", parentId: "tickets.page.queue", order: 50 },
{ id: "tickets.action.resolve", moduleId: "tickets", kind: "action", label: "Resolve ticket", parentId: "tickets.page.detail", order: 60 }
]
};
export default ticketsModule;
+237
View File
@@ -0,0 +1,237 @@
.tickets-page {
height: 100%;
min-height: 0;
overflow: hidden;
}
.tickets-search {
flex: 1 1 540px;
}
.tickets-status-filter {
display: flex;
align-items: center;
gap: 7px;
}
.tickets-status-filter > span,
.tickets-count {
color: var(--text-soft);
font-size: 0.8rem;
}
.tickets-count {
margin-left: auto;
}
.tickets-availability {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface-subtle, var(--surface));
color: var(--text-soft);
font-size: 0.78rem;
}
.tickets-workspace {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: minmax(390px, 42%) minmax(0, 1fr);
}
.tickets-list-viewport,
.ticket-detail-viewport {
min-height: 0;
padding: 14px;
}
.tickets-list-viewport {
border-right: 1px solid var(--border);
background: var(--surface-subtle, var(--surface));
}
.ticket-list-state {
display: flex;
align-items: center;
gap: 5px;
}
.ticket-detail {
width: min(100%, 980px);
margin: 0 auto;
padding: 4px 8px 32px;
}
.ticket-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 15px;
border-bottom: 1px solid var(--border);
}
.ticket-detail-header h1 {
margin: 4px 0 0;
font-size: 1.4rem;
letter-spacing: 0;
}
.ticket-eyebrow,
.ticket-muted {
color: var(--text-soft);
font-size: 0.82rem;
}
.ticket-detail-badges,
.ticket-detail-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
}
.ticket-detail-actions {
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.ticket-description {
max-width: 78ch;
margin: 18px 0;
white-space: pre-wrap;
}
.ticket-facts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-compact);
background: var(--border);
}
.ticket-facts > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
padding: 11px;
background: var(--surface-raised);
}
.ticket-facts span {
color: var(--text-soft);
font-size: 0.76rem;
}
.ticket-facts strong {
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.86rem;
}
.ticket-section {
margin-top: 24px;
}
.ticket-section h2 {
display: flex;
align-items: center;
gap: 7px;
margin: 0 0 10px;
font-size: 0.98rem;
letter-spacing: 0;
}
.ticket-section h2 span {
color: var(--text-soft);
font-size: 0.8rem;
}
.ticket-link-list {
margin: 0;
padding: 0;
list-style: none;
}
.ticket-link-list li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 9px 2px;
border-bottom: 1px solid var(--border);
}
.ticket-link-list small {
color: var(--text-soft);
}
.ticket-comment-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: end;
gap: 8px;
}
.ticket-comment-form textarea {
grid-row: span 2;
resize: vertical;
}
.ticket-editor-dialog {
width: min(720px, calc(100vw - 32px));
}
.ticket-editor-form label {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
@media (max-width: 980px) {
.tickets-workspace {
grid-template-columns: minmax(330px, 46%) minmax(0, 1fr);
}
.ticket-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.tickets-count {
margin-left: 0;
}
.tickets-workspace {
grid-template-columns: 1fr;
}
.tickets-list-viewport {
max-height: 42vh;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.ticket-comment-form {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.ticket-detail-header,
.ticket-link-list li {
flex-direction: column;
}
.ticket-facts {
grid-template-columns: 1fr;
}
}