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