feat(helpdesk): configure ticket routing profiles

This commit is contained in:
2026-08-22 11:41:45 +02:00
parent 064ec6158d
commit 32bd55d5bd
24 changed files with 1584 additions and 102 deletions
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@govoplan/helpdesk-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/helpdesk.css": "./src/styles/helpdesk.css"
},
"scripts": {
"test": "npm run test:interface-pattern",
"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
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/helpdesk/HelpdeskProfilesPage.tsx", "utf8");
const styles = fs.readFileSync("src/styles/helpdesk.css", "utf8");
assert.ok(page.includes("WorkspaceActionBar"), "Helpdesk uses the semantic shared action bar");
assert.ok(page.includes("refreshable"), "The Helpdesk profile page exposes Reload");
assert.ok(page.includes("DocumentationHelpLink"), "Helpdesk exposes contextual documentation");
assert.ok(page.includes("PageScrollViewport"), "Helpdesk owns bounded profile and detail scrolling");
assert.ok(page.includes("<Dialog"), "Helpdesk profile edits use the shared dialog");
assert.ok(page.includes("FieldLabel"), "Helpdesk profile fields use shared labels and help");
assert.ok(page.includes("StatusBadge"), "Profile activation is not conveyed by color alone");
assert.ok(!page.includes("window.alert("), "Helpdesk must not use browser alerts");
assert.ok(styles.includes("@media (max-width: 760px)"), "Helpdesk retains a responsive profile layout");
console.log("Helpdesk interface pattern contract passed.");
+35
View File
@@ -0,0 +1,35 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type ServiceProfile = {
tenant_id: string;
profile_id: string;
profile_key: string;
revision: number;
label: string;
description?: string | null;
queue_ref: string;
ticket_types: string[];
priorities: string[];
target_minutes: Record<string, number>;
default_target_minutes?: number | null;
active: boolean;
sort_order: number;
recorded_at: string;
change_reason: string;
};
export function listServiceProfiles(settings: ApiSettings, signal?: AbortSignal): Promise<{ profiles: ServiceProfile[] }> {
return apiFetch(settings, "/api/v1/helpdesk/service-profiles", { signal });
}
export function saveServiceProfile(settings: ApiSettings, profile: ServiceProfile, expectedRevision: number | null): Promise<ServiceProfile> {
return apiFetch(settings, `/api/v1/helpdesk/service-profiles/${encodeURIComponent(profile.profile_id)}`, {
method: "PUT",
body: JSON.stringify({
profile,
expected_revision: expectedRevision,
idempotency_key: crypto.randomUUID()
})
});
}
@@ -0,0 +1,246 @@
import { Headset, Pencil, Plus } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
Button,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FieldLabel,
FormLayout,
LoadingIndicator,
PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
WorkspaceActionBar,
WorkspaceFrame,
type PlatformRouteContext
} from "@govoplan/core-webui";
import { listServiceProfiles, saveServiceProfile, type ServiceProfile } from "../../api/helpdesk";
type ProfileValues = {
key: string;
label: string;
description: string;
queueRef: string;
ticketTypes: string[];
priorities: string[];
lowMinutes: string;
normalMinutes: string;
highMinutes: string;
urgentMinutes: string;
defaultMinutes: string;
active: boolean;
sortOrder: string;
changeReason: string;
};
const TYPES = ["request", "incident", "problem", "report"];
const PRIORITIES = ["low", "normal", "high", "urgent"];
export default function HelpdeskProfilesPage({ settings, auth }: PlatformRouteContext) {
const [profiles, setProfiles] = useState<ServiceProfile[]>([]);
const [selectedId, setSelectedId] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [editorError, setEditorError] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<ServiceProfile | null>(null);
function reload(signal?: AbortSignal) {
setLoading(true);
setError("");
return listServiceProfiles(settings, signal).
then((result) => {
setProfiles(result.profiles);
setSelectedId((current) => result.profiles.some((item) => item.profile_id === current)
? current
: result.profiles[0]?.profile_id ?? "");
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason));
}).
finally(() => setLoading(false));
}
useEffect(() => {
const controller = new AbortController();
void reload(controller.signal);
return () => controller.abort();
}, [settings]);
const selected = useMemo(() => profiles.find((item) => item.profile_id === selectedId) ?? null, [profiles, selectedId]);
async function save(values: ProfileValues) {
setSaving(true);
setEditorError("");
try {
const now = new Date().toISOString();
const profile: ServiceProfile = {
tenant_id: auth.active_tenant?.id ?? auth.tenant.id,
profile_id: editing?.profile_id ?? crypto.randomUUID(),
profile_key: values.key,
revision: editing ? editing.revision + 1 : 1,
label: values.label,
description: values.description || null,
queue_ref: values.queueRef,
ticket_types: values.ticketTypes,
priorities: values.priorities,
target_minutes: targetMinutes(values),
default_target_minutes: positiveNumber(values.defaultMinutes),
active: values.active,
sort_order: Number(values.sortOrder),
recorded_at: now,
change_reason: values.changeReason
};
const saved = await saveServiceProfile(settings, profile, editing?.revision ?? null);
setEditorOpen(false);
setEditing(null);
await reload();
setSelectedId(saved.profile_id);
} catch (reason) {
setEditorError(message(reason));
} finally {
setSaving(false);
}
}
return (
<main className="helpdesk-page">
<WorkspaceFrame className="helpdesk-shell" label="Helpdesk profiles" interfaceId="helpdesk.route.profiles" helpContextId="helpdesk.route.profiles" helpModuleId="helpdesk">
<WorkspaceActionBar
scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void reload(), loading }}
contextActions={<span className="helpdesk-count">{profiles.length} profiles · ordered first match wins</span>}
helpAction={<DocumentationHelpLink reference={{ topicId: "helpdesk.configure-routing-profiles", documentationType: "admin" }} label="Open Helpdesk profile documentation" />}
createAction={<Button type="button" variant="primary" onClick={() => { setEditing(null); setEditorError(""); setEditorOpen(true); }}><Plus size={16} /> New profile</Button>}
/>
{error && <DismissibleAlert tone="danger" onDismiss={() => setError("")}>{error}</DismissibleAlert>}
<div className="helpdesk-workspace">
<PageScrollViewport className="helpdesk-list-viewport">
{loading && <LoadingIndicator label="Loading Helpdesk profiles" />}
{!loading && profiles.length === 0 && <StatePanel size="compact" title="No routing profiles" description="Create a profile or continue using manual Ticket queues and targets." />}
<SelectionList variant="navigation" label="Helpdesk service profiles">
{profiles.map((profile) =>
<SelectionListItem key={profile.profile_id} selected={profile.profile_id === selectedId} onClick={() => setSelectedId(profile.profile_id)}>
<SelectionListItemContent leading={<Headset size={18} />} title={profile.label} description={`${profile.queue_ref} · order ${profile.sort_order} · revision ${profile.revision}`} />
<StatusBadge status={profile.active ? "active" : "inactive"} label={profile.active ? "Active" : "Inactive"} />
</SelectionListItem>
)}
</SelectionList>
</PageScrollViewport>
<PageScrollViewport className="helpdesk-detail-viewport">
{selected ? <ProfileDetail profile={selected} onEdit={() => { setEditing(selected); setEditorError(""); setEditorOpen(true); }} /> : <StatePanel size="fill" title="Helpdesk routing profiles" description="Select a profile to inspect its match and target semantics." />}
</PageScrollViewport>
</div>
</WorkspaceFrame>
<ProfileEditorDialog open={editorOpen} profile={editing} saving={saving} error={editorError} onClose={() => { setEditorOpen(false); setEditorError(""); }} onSave={save} />
</main>
);
}
function ProfileDetail({ profile, onEdit }: { profile: ServiceProfile; onEdit: () => void }) {
return <article className="helpdesk-detail">
<header className="helpdesk-detail-header">
<div><span className="helpdesk-eyebrow">{profile.profile_key} · order {profile.sort_order}</span><h1>{profile.label}</h1></div>
<div className="helpdesk-detail-actions"><StatusBadge status={profile.active ? "active" : "inactive"} label={profile.active ? "Active" : "Inactive"} /><Button type="button" onClick={onEdit}><Pencil size={16} /> Edit</Button></div>
</header>
{profile.description && <p className="helpdesk-description">{profile.description}</p>}
<div className="helpdesk-facts">
<Fact label="Queue" value={profile.queue_ref} />
<Fact label="Revision" value={String(profile.revision)} />
<Fact label="Ticket types" value={profile.ticket_types.map(humanize).join(", ")} />
<Fact label="Priorities" value={profile.priorities.map(humanize).join(", ")} />
</div>
<section className="helpdesk-targets"><h2>Service targets</h2><div>{PRIORITIES.map((priority) => <Fact key={priority} label={humanize(priority)} value={duration(profile.target_minutes[priority] ?? profile.default_target_minutes)} />)}</div></section>
<section className="helpdesk-policy-note"><h2>Applied consequence</h2><p>The first active matching profile sets the Ticket queue and target. If no profile matches, Ticket intake succeeds and authorized staff route it manually.</p></section>
</article>;
}
function ProfileEditorDialog({ open, profile, saving, error, onClose, onSave }: {
open: boolean;
profile: ServiceProfile | null;
saving: boolean;
error: string;
onClose: () => void;
onSave: (values: ProfileValues) => Promise<void>;
}) {
const [values, setValues] = useState(() => profileValues(profile));
useEffect(() => { if (open) setValues(profileValues(profile)); }, [open, profile]);
function set<K extends keyof ProfileValues>(key: K, value: ProfileValues[K]) { setValues((current) => ({ ...current, [key]: value })); }
function submit(event: FormEvent) { event.preventDefault(); void onSave(values); }
return <Dialog open={open} title={profile ? "Edit Helpdesk profile" : "New Helpdesk profile"} onClose={onClose} closeDisabled={saving} className="helpdesk-editor-dialog" footer={<><Button onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="helpdesk-profile-form" variant="primary" disabled={saving}>{saving ? "Saving..." : "Save"}</Button></>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormLayout id="helpdesk-profile-form" columns={2} gap="compact" collapseAt="narrow" className="helpdesk-editor-form" onSubmit={submit}>
<label><FieldLabel help="Stable and immutable after creation.">Profile key</FieldLabel><input value={values.key} disabled={Boolean(profile)} required maxLength={120} onChange={(event) => set("key", event.target.value)} /></label>
<label><FieldLabel>Label</FieldLabel><input value={values.label} required maxLength={255} onChange={(event) => set("label", event.target.value)} /></label>
<label><FieldLabel>Queue reference</FieldLabel><input value={values.queueRef} required maxLength={255} onChange={(event) => set("queueRef", event.target.value)} /></label>
<label><FieldLabel help="Lower values are evaluated first.">Sort order</FieldLabel><input type="number" min="0" max="100000" value={values.sortOrder} required onChange={(event) => set("sortOrder", event.target.value)} /></label>
<label><FieldLabel>Ticket types</FieldLabel><select multiple value={values.ticketTypes} required onChange={(event) => set("ticketTypes", selectedValues(event.currentTarget))}>{TYPES.map((item) => <option key={item} value={item}>{humanize(item)}</option>)}</select></label>
<label><FieldLabel>Priorities</FieldLabel><select multiple value={values.priorities} required onChange={(event) => set("priorities", selectedValues(event.currentTarget))}>{PRIORITIES.map((item) => <option key={item} value={item}>{humanize(item)}</option>)}</select></label>
<label className="wide"><FieldLabel>Description</FieldLabel><textarea rows={3} value={values.description} maxLength={10_000} onChange={(event) => set("description", event.target.value)} /></label>
<TargetField label="Low target (minutes)" value={values.lowMinutes} onChange={(value) => set("lowMinutes", value)} />
<TargetField label="Normal target (minutes)" value={values.normalMinutes} onChange={(value) => set("normalMinutes", value)} />
<TargetField label="High target (minutes)" value={values.highMinutes} onChange={(value) => set("highMinutes", value)} />
<TargetField label="Urgent target (minutes)" value={values.urgentMinutes} onChange={(value) => set("urgentMinutes", value)} />
<TargetField label="Default target (minutes)" value={values.defaultMinutes} onChange={(value) => set("defaultMinutes", value)} />
<label className="helpdesk-active-field"><input type="checkbox" checked={values.active} onChange={(event) => set("active", event.target.checked)} /> Active for new Ticket routing</label>
<label className="wide"><FieldLabel help="Stored with immutable configuration history.">Change reason</FieldLabel><input value={values.changeReason} required maxLength={1_000} onChange={(event) => set("changeReason", event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function TargetField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return <label><FieldLabel>{label}</FieldLabel><input type="number" min="1" max="525600" value={value} onChange={(event) => onChange(event.target.value)} /></label>;
}
function Fact({ label, value }: { label: string; value: string }) { return <div><span>{label}</span><strong>{value}</strong></div>; }
function profileValues(profile: ServiceProfile | null): ProfileValues {
return {
key: profile?.profile_key ?? "",
label: profile?.label ?? "",
description: profile?.description ?? "",
queueRef: profile?.queue_ref ?? "",
ticketTypes: profile?.ticket_types ?? ["request"],
priorities: profile?.priorities ?? ["normal"],
lowMinutes: numberText(profile?.target_minutes.low),
normalMinutes: numberText(profile?.target_minutes.normal),
highMinutes: numberText(profile?.target_minutes.high),
urgentMinutes: numberText(profile?.target_minutes.urgent),
defaultMinutes: numberText(profile?.default_target_minutes),
active: profile?.active ?? true,
sortOrder: String(profile?.sort_order ?? 100),
changeReason: profile ? "Updated Helpdesk routing policy." : "Created Helpdesk routing policy."
};
}
function targetMinutes(values: ProfileValues): Record<string, number> {
const result: Record<string, number> = {};
for (const [priority, minutes] of [
["low", positiveNumber(values.lowMinutes)],
["normal", positiveNumber(values.normalMinutes)],
["high", positiveNumber(values.highMinutes)],
["urgent", positiveNumber(values.urgentMinutes)]
] as const) {
if (minutes !== null) result[priority] = minutes;
}
return result;
}
function positiveNumber(value: string): number | null { return value ? Number(value) : null; }
function numberText(value?: number | null): string { return value ? String(value) : ""; }
function selectedValues(select: HTMLSelectElement): string[] { return Array.from(select.selectedOptions, (option) => option.value); }
function humanize(value: string): string { return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); }
function duration(minutes?: number | null): string { return minutes ? `${minutes} minutes` : "No automatic target"; }
function message(reason: unknown): string { return reason instanceof Error ? reason.message : "Helpdesk profiles could not be updated."; }
+1
View File
@@ -0,0 +1 @@
export { helpdeskModule as default, helpdeskModule } from "./module";
+39
View File
@@ -0,0 +1,39 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/helpdesk.css";
const HelpdeskProfilesPage = lazy(() => import("./features/helpdesk/HelpdeskProfilesPage"));
export const helpdeskModule: PlatformWebModule = {
id: "helpdesk",
label: "Helpdesk",
version: "0.1.20",
optionalDependencies: ["tickets"],
routes: [
{
path: "/helpdesk",
anyOf: ["helpdesk:workspace:admin"],
order: 82,
surfaceId: "helpdesk.route.profiles",
render: (context) => createElement(HelpdeskProfilesPage, context)
}
],
navItems: [
{
to: "/helpdesk",
label: "Helpdesk profiles",
iconName: "headset",
anyOf: ["helpdesk:workspace:admin"],
order: 82,
surfaceId: "helpdesk.navigation"
}
],
viewSurfaces: [
{ id: "helpdesk.navigation", moduleId: "helpdesk", kind: "navigation", label: "Helpdesk navigation", order: 10 },
{ id: "helpdesk.route.profiles", moduleId: "helpdesk", kind: "route", label: "Helpdesk service profiles", order: 20 },
{ id: "helpdesk.action.save-profile", moduleId: "helpdesk", kind: "action", label: "Save Helpdesk service profile", parentId: "helpdesk.route.profiles", order: 30 }
]
};
export default helpdeskModule;
+24
View File
@@ -0,0 +1,24 @@
.helpdesk-page { height: 100%; min-height: 0; overflow: hidden; }
.helpdesk-count { color: var(--text-soft); font-size: 0.8rem; }
.helpdesk-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(340px, 38%) minmax(0, 1fr); }
.helpdesk-list-viewport, .helpdesk-detail-viewport { min-height: 0; padding: 14px; }
.helpdesk-list-viewport { border-right: 1px solid var(--border); background: var(--surface-subtle, var(--surface)); }
.helpdesk-detail { width: min(100%, 920px); margin: 0 auto; padding: 4px 8px 30px; }
.helpdesk-detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 15px; border-bottom: 1px solid var(--border); }
.helpdesk-detail-header h1 { margin: 4px 0 0; font-size: 1.4rem; letter-spacing: 0; }
.helpdesk-eyebrow { color: var(--text-soft); font-size: 0.8rem; }
.helpdesk-detail-actions { display: flex; align-items: center; gap: 8px; }
.helpdesk-description { max-width: 76ch; margin: 18px 0; white-space: pre-wrap; }
.helpdesk-facts, .helpdesk-targets > div { 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); }
.helpdesk-facts > div, .helpdesk-targets > div > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; padding: 11px; background: var(--surface-raised); }
.helpdesk-facts span, .helpdesk-targets span { color: var(--text-soft); font-size: 0.76rem; }
.helpdesk-facts strong, .helpdesk-targets strong { overflow: hidden; text-overflow: ellipsis; font-size: 0.86rem; }
.helpdesk-targets, .helpdesk-policy-note { margin-top: 24px; }
.helpdesk-targets h2, .helpdesk-policy-note h2 { margin: 0 0 10px; font-size: 0.98rem; letter-spacing: 0; }
.helpdesk-editor-dialog { width: min(760px, calc(100vw - 32px)); }
.helpdesk-editor-form label { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
.helpdesk-editor-form select[multiple] { min-height: 116px; }
.helpdesk-active-field { align-self: end; flex-direction: row !important; align-items: center; min-height: 38px; }
@media (max-width: 900px) { .helpdesk-facts, .helpdesk-targets > div { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 760px) { .helpdesk-workspace { grid-template-columns: 1fr; } .helpdesk-list-viewport { max-height: 40vh; border-right: 0; border-bottom: 1px solid var(--border); } }
@media (max-width: 520px) { .helpdesk-detail-header { flex-direction: column; } .helpdesk-facts, .helpdesk-targets > div { grid-template-columns: 1fr; } }