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
@@ -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."; }