From a9faafb2dc5e392870b51360e3ac54390d66cf6f Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 22 Aug 2026 11:57:53 +0200 Subject: [PATCH] feat(tickets): complete governed ticket workspace --- src/govoplan_tickets/backend/manifest.py | 8 + webui/scripts/test-interface-pattern.mjs | 5 + webui/src/api/tickets.ts | 50 +++++ webui/src/features/tickets/TicketsPage.tsx | 215 ++++++++++++++++++++- webui/src/module.ts | 6 +- webui/src/styles/tickets.css | 98 ++++++++++ 6 files changed, 377 insertions(+), 5 deletions(-) diff --git a/src/govoplan_tickets/backend/manifest.py b/src/govoplan_tickets/backend/manifest.py index 01610a5..104c1fa 100644 --- a/src/govoplan_tickets/backend/manifest.py +++ b/src/govoplan_tickets/backend/manifest.py @@ -229,6 +229,10 @@ DOCUMENTATION = ( "tickets.action.assign", "tickets.action.resolve", "tickets.action.escalate", + "tickets.action.participants", + "tickets.action.link", + "tickets.action.delete", + "tickets.section.history", "tickets.field.queue", "tickets.field.service-target", ], @@ -337,6 +341,10 @@ manifest = ModuleManifest( ViewSurface(id="tickets.page.detail", module_id=MODULE_ID, kind="section", label="Ticket details", parent_id="tickets.route.workspace", order=40), ViewSurface(id="tickets.action.report", module_id=MODULE_ID, kind="action", label="Report ticket", parent_id="tickets.page.queue", order=50), ViewSurface(id="tickets.action.resolve", module_id=MODULE_ID, kind="action", label="Resolve ticket", parent_id="tickets.page.detail", order=60), + ViewSurface(id="tickets.section.history", module_id=MODULE_ID, kind="section", label="Immutable ticket history", parent_id="tickets.page.detail", order=70), + ViewSurface(id="tickets.action.participants", module_id=MODULE_ID, kind="action", label="Manage ticket participants", parent_id="tickets.page.detail", order=80), + ViewSurface(id="tickets.action.link", module_id=MODULE_ID, kind="action", label="Add or remove ticket reference", parent_id="tickets.page.detail", order=90), + ViewSurface(id="tickets.action.delete", module_id=MODULE_ID, kind="action", label="Delete ticket", parent_id="tickets.page.detail", order=100), ), ), tenant_summary_providers=(_tenant_summary,), diff --git a/webui/scripts/test-interface-pattern.mjs b/webui/scripts/test-interface-pattern.mjs index bdd72ad..5acb67f 100644 --- a/webui/scripts/test-interface-pattern.mjs +++ b/webui/scripts/test-interface-pattern.mjs @@ -11,6 +11,11 @@ assert.ok(page.includes("PageScrollViewport"), "Tickets owns bounded queue and d assert.ok(page.includes("]*\bonClick\s*=/.test(page), "Tickets uses semantic interactive elements"); assert.ok(styles.includes("@media (max-width: 760px)"), "Tickets retains a responsive queue-detail layout"); diff --git a/webui/src/api/tickets.ts b/webui/src/api/tickets.ts index f3606a4..dfc0890 100644 --- a/webui/src/api/tickets.ts +++ b/webui/src/api/tickets.ts @@ -65,6 +65,15 @@ export type TicketAvailability = { consequences: Record; }; +export type TicketHistoryEntry = { + revision: number; + event_type: string; + occurred_at: string; + actor_id?: string | null; + reason: string; + details: Record; +}; + export function listTickets( settings: ApiSettings, options: { statuses?: string[]; priorities?: string[]; ticketTypes?: string[]; queueRef?: string; query?: string; limit?: number }, @@ -84,6 +93,14 @@ export function getTicketAvailability(settings: ApiSettings, signal?: AbortSigna return apiFetch(settings, "/api/v1/tickets/availability", { signal }); } +export function getTicket(settings: ApiSettings, ticketId: string, signal?: AbortSignal): Promise { + return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(ticketId)}`, { signal }); +} + +export function listTicketHistory(settings: ApiSettings, ticketId: string, signal?: AbortSignal): Promise<{ history: TicketHistoryEntry[] }> { + return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(ticketId)}/history`, { signal }); +} + export function createTicket(settings: ApiSettings, record: TicketRecord): Promise { return apiFetch(settings, "/api/v1/tickets", { method: "POST", @@ -114,6 +131,27 @@ export function assignTicket(settings: ApiSettings, record: TicketRecord, assign }); } +export function replaceTicketParticipants(settings: ApiSettings, record: TicketRecord, participants: TicketSubject[], changeReason: string): Promise { + return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/participants`, { + method: "PUT", + body: JSON.stringify({ ...mutation(record, changeReason), participants }) + }); +} + +export function addTicketLink(settings: ApiSettings, record: TicketRecord, link: TicketLink, changeReason: string): Promise { + return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/links`, { + method: "POST", + body: JSON.stringify({ ...mutation(record, changeReason), link }) + }); +} + +export function removeTicketLink(settings: ApiSettings, record: TicketRecord, linkId: string, changeReason: string): Promise { + return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/links/${encodeURIComponent(linkId)}`, { + method: "DELETE", + body: JSON.stringify({ ...mutation(record, changeReason), changes: {} }) + }); +} + export function resolveTicket( settings: ApiSettings, record: TicketRecord, @@ -153,3 +191,15 @@ export function escalateTicket(settings: ApiSettings, record: TicketRecord, case }) }) as Promise<{ ticket: TicketRecord; escalation: Record }>; } + +export function deleteTicket(settings: ApiSettings, record: TicketRecord, reason: string): Promise { + return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}`, { + method: "DELETE", + body: JSON.stringify({ + expected_revision: record.revision, + occurred_at: new Date().toISOString(), + reason, + idempotency_key: crypto.randomUUID() + }) + }); +} diff --git a/webui/src/features/tickets/TicketsPage.tsx b/webui/src/features/tickets/TicketsPage.tsx index 62749b3..dd934cb 100644 --- a/webui/src/features/tickets/TicketsPage.tsx +++ b/webui/src/features/tickets/TicketsPage.tsx @@ -1,10 +1,13 @@ import { MessageSquarePlus, + Link2, Pencil, Plus, Search, Send, TicketCheck, + Trash2, + Users, UserRoundCheck } from "lucide-react"; import { @@ -15,6 +18,7 @@ import { } from "react"; import { Button, + ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, @@ -35,14 +39,22 @@ import { } from "@govoplan/core-webui"; import { addTicketComment, + addTicketLink, assignTicket, createTicket, + deleteTicket, escalateTicket, + getTicket, getTicketAvailability, + listTicketHistory, listTickets, + removeTicketLink, + replaceTicketParticipants, resolveTicket, triageTicket, type TicketAvailability, + type TicketHistoryEntry, + type TicketLink, type TicketRecord, type TicketSubject } from "../../api/tickets"; @@ -71,6 +83,7 @@ export default function TicketsPage({ settings, auth }: PlatformRouteContext) { const [selectedId, setSelectedId] = useState(""); const [total, setTotal] = useState(0); const [availability, setAvailability] = useState(null); + const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [dialogError, setDialogError] = useState(""); @@ -79,11 +92,15 @@ export default function TicketsPage({ settings, auth }: PlatformRouteContext) { const [assignmentOpen, setAssignmentOpen] = useState(false); const [resolutionOpen, setResolutionOpen] = useState(false); const [escalationOpen, setEscalationOpen] = useState(false); + const [participantsOpen, setParticipantsOpen] = useState(false); + const [linkOpen, setLinkOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = 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 canAdmin = hasScope(auth, "tickets:ticket:admin"); const canCreateCase = hasScope(auth, "cases:case:create"); function reload(signal?: AbortSignal) { @@ -124,6 +141,24 @@ export default function TicketsPage({ settings, auth }: PlatformRouteContext) { [tickets, selectedId] ); + useEffect(() => { + if (!selectedId) { + setHistory([]); + return; + } + const controller = new AbortController(); + void Promise.all([ + getTicket(settings, selectedId, controller.signal), + listTicketHistory(settings, selectedId, controller.signal) + ]).then(([record, result]) => { + setTickets((current) => current.map((item) => item.ticket_id === record.ticket_id ? record : item)); + setHistory(result.history); + }).catch((reason) => { + if ((reason as Error).name !== "AbortError") setError(message(reason, "Ticket details could not be loaded.")); + }); + return () => controller.abort(); + }, [settings, selectedId]); + function submitSearch(event: FormEvent) { event.preventDefault(); setSubmittedQuery(query.trim()); @@ -223,6 +258,44 @@ export default function TicketsPage({ settings, auth }: PlatformRouteContext) { }); } + async function saveParticipants(participants: TicketSubject[], reason: string) { + if (!selected) return; + await runAction(async () => { + const saved = await replaceTicketParticipants(settings, selected, participants, reason); + setParticipantsOpen(false); + return saved; + }); + } + + async function saveLink(link: TicketLink, reason: string) { + if (!selected) return; + await runAction(async () => { + const saved = await addTicketLink(settings, selected, link, reason); + setLinkOpen(false); + return saved; + }); + } + + async function removeLink(linkId: string) { + if (!selected) return; + await runAction(() => removeTicketLink(settings, selected, linkId, "Removed an obsolete ticket reference.")); + } + + async function confirmDelete() { + if (!selected) return; + setSaving(true); + setDialogError(""); + try { + await deleteTicket(settings, selected, "Removed the ticket from active operational work."); + setDeleteOpen(false); + await reload(); + } catch (reason) { + setDialogError(message(reason, "The ticket could not be deleted.")); + } finally { + setSaving(false); + } + } + async function saveComment(body: string, visibility: "internal" | "external") { if (!selected) return; await runAction(async () => (await addTicketComment(settings, selected, body, visibility)).ticket); @@ -310,7 +383,10 @@ export default function TicketsPage({ settings, auth }: PlatformRouteContext) { canTriage={canTriage} canAssign={canAssign} canResolve={canResolve} + canAdmin={canAdmin} + canComment={canReport || canTriage} canCreateCase={canCreateCase} + history={history} saving={saving} onEdit={() => { setEditing(selected); @@ -320,6 +396,10 @@ export default function TicketsPage({ settings, auth }: PlatformRouteContext) { onAssign={() => setAssignmentOpen(true)} onResolve={() => setResolutionOpen(true)} onEscalate={() => setEscalationOpen(true)} + onParticipants={() => setParticipantsOpen(true)} + onAddLink={() => setLinkOpen(true)} + onRemoveLink={removeLink} + onDelete={() => setDeleteOpen(true)} onComment={saveComment} /> : @@ -331,27 +411,40 @@ export default function TicketsPage({ settings, auth }: PlatformRouteContext) { setAssignmentOpen(false)} onSave={saveAssignment} /> setResolutionOpen(false)} onSave={saveResolution} /> setEscalationOpen(false)} onSave={saveEscalation} /> + setParticipantsOpen(false)} onSave={saveParticipants} /> + setLinkOpen(false)} onSave={saveLink} /> + setDeleteOpen(false)} onConfirm={() => void confirmDelete()} /> ); } -function TicketDetail({ record, availability, canTriage, canAssign, canResolve, canCreateCase, saving, onEdit, onAssign, onResolve, onEscalate, onComment }: { +function TicketDetail({ record, availability, history, canTriage, canAssign, canResolve, canAdmin, canComment, canCreateCase, saving, onEdit, onAssign, onResolve, onEscalate, onParticipants, onAddLink, onRemoveLink, onDelete, onComment }: { record: TicketRecord; availability: TicketAvailability | null; + history: TicketHistoryEntry[]; canTriage: boolean; canAssign: boolean; canResolve: boolean; + canAdmin: boolean; + canComment: boolean; canCreateCase: boolean; saving: boolean; onEdit: () => void; onAssign: () => void; onResolve: () => void; onEscalate: () => void; + onParticipants: () => void; + onAddLink: () => void; + onRemoveLink: (linkId: string) => Promise; + onDelete: () => void; onComment: (body: string, visibility: "internal" | "external") => Promise; }) { const [comment, setComment] = useState(""); const [visibility, setVisibility] = useState<"internal" | "external">("internal"); + useEffect(() => { + if (!canTriage) setVisibility("external"); + }, [canTriage, record.ticket_id]); return (
@@ -367,6 +460,8 @@ function TicketDetail({ record, availability, canTriage, canAssign, canResolve,
{canTriage && } {canAssign && } + {canTriage && } + {canTriage && } {canResolve && } {canTriage && canCreateCase && availability?.case_escalation.available && }
@@ -391,11 +486,19 @@ function TicketDetail({ record, availability, canTriage, canAssign, canResolve,

References and attachments {record.links.length}

{record.links.length === 0 ?

No typed references have been added.

:
    - {record.links.map((link) =>
  • {link.url ? {link.label || link.resource_id} : {link.label || link.resource_id}}{humanize(link.kind)} · {link.owner_module}
  • )} + {record.links.map((link) =>
  • {link.url ? {link.label || link.resource_id} : {link.label || link.resource_id}}{humanize(link.kind)} · {link.owner_module}
    {canTriage && }
  • )}
}
+

Participants {record.participants.length}

+ {record.participants.length === 0 ?

No additional participants are recorded.

:
    {record.participants.map((subject) =>
  • {subject.label || subject.id}{humanize(subject.role || subject.kind)}
  • )}
} +
+
+

Immutable history {history.length}

+ {history.length === 0 ?

History is loading or unavailable.

:
    {history.map((entry) =>
  1. {humanize(entry.event_type)}Revision {entry.revision} · {formatDateTime(entry.occurred_at)}

    {entry.reason}

  2. )}
} +
+ {canComment &&

Add comment

{ event.preventDefault(); @@ -405,12 +508,13 @@ function TicketDetail({ record, availability, canTriage, canAssign, canResolve, }}>