feat(helpdesk): configure ticket routing profiles
This commit is contained in:
@@ -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."; }
|
||||
Reference in New Issue
Block a user