feat(tickets): complete governed ticket workspace

This commit is contained in:
2026-08-22 11:57:53 +02:00
parent c128d51844
commit a9faafb2dc
6 changed files with 377 additions and 5 deletions
+8
View File
@@ -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,),
+5
View File
@@ -11,6 +11,11 @@ assert.ok(page.includes("PageScrollViewport"), "Tickets owns bounded queue and d
assert.ok(page.includes("<Dialog"), "Ticket mutations use shared focus-contained dialogs");
assert.ok(page.includes("FieldLabel"), "Ticket fields use the shared label and help contract");
assert.ok(page.includes("StatusBadge"), "Ticket state is not conveyed by color alone");
assert.ok(page.includes("listTicketHistory"), "The workspace projects immutable ticket history");
assert.ok(page.includes("ParticipantsDialog"), "Participants use a governed shared dialog");
assert.ok(page.includes("LinkDialog"), "References and attachments use a governed shared dialog");
assert.ok(page.includes("<ConfirmDialog"), "Administrative deletion is separately confirmed");
assert.ok(page.includes("ticket-destructive-actions"), "Destructive ticket actions are visually separated");
assert.ok(!page.includes("window.alert("), "Tickets must not use browser alerts");
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Tickets uses semantic interactive elements");
assert.ok(styles.includes("@media (max-width: 760px)"), "Tickets retains a responsive queue-detail layout");
+50
View File
@@ -65,6 +65,15 @@ export type TicketAvailability = {
consequences: Record<string, string>;
};
export type TicketHistoryEntry = {
revision: number;
event_type: string;
occurred_at: string;
actor_id?: string | null;
reason: string;
details: Record<string, unknown>;
};
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<TicketRecord> {
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<TicketRecord> {
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<TicketRecord> {
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<TicketRecord> {
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<TicketRecord> {
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<string, unknown> }>;
}
export function deleteTicket(settings: ApiSettings, record: TicketRecord, reason: string): Promise<TicketRecord> {
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()
})
});
}
+211 -4
View File
@@ -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<TicketAvailability | null>(null);
const [history, setHistory] = useState<TicketHistoryEntry[]>([]);
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}
/> :
<StatePanel size="fill" title="Tickets" description="Select a ticket to inspect and continue its work." />
@@ -331,27 +411,40 @@ export default function TicketsPage({ settings, auth }: PlatformRouteContext) {
<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} />
<ParticipantsDialog open={participantsOpen} record={selected} saving={saving} onClose={() => setParticipantsOpen(false)} onSave={saveParticipants} />
<LinkDialog open={linkOpen} saving={saving} onClose={() => setLinkOpen(false)} onSave={saveLink} />
<ConfirmDialog open={deleteOpen} title="Delete ticket" message="Remove this ticket from active work? Its immutable history is retained for governed recovery and audit." confirmLabel="Delete ticket" tone="danger" busy={saving} interfaceId="tickets.action.delete" helpContextId="tickets.action.delete" helpModuleId="tickets" onCancel={() => setDeleteOpen(false)} onConfirm={() => void confirmDelete()} />
</main>
);
}
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<void>;
onDelete: () => void;
onComment: (body: string, visibility: "internal" | "external") => Promise<void>;
}) {
const [comment, setComment] = useState("");
const [visibility, setVisibility] = useState<"internal" | "external">("internal");
useEffect(() => {
if (!canTriage) setVisibility("external");
}, [canTriage, record.ticket_id]);
return (
<article className="ticket-detail">
<header className="ticket-detail-header">
@@ -367,6 +460,8 @@ function TicketDetail({ record, availability, canTriage, canAssign, canResolve,
<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>}
{canTriage && <Button type="button" interfaceId="tickets.action.participants" helpContextId="tickets.action.participants" helpModuleId="tickets" onClick={onParticipants}><Users size={16} /> Participants</Button>}
{canTriage && <Button type="button" interfaceId="tickets.action.link" helpContextId="tickets.action.link" helpModuleId="tickets" onClick={onAddLink}><Link2 size={16} /> Add reference</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>
@@ -391,11 +486,19 @@ function TicketDetail({ record, availability, canTriage, canAssign, canResolve,
<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>)}
{record.links.map((link) => <li key={link.link_id}><div>{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></div>{canTriage && <Button type="button" variant="ghost" className="compact-action" disabled={saving} onClick={() => void onRemoveLink(link.link_id)}>Remove</Button>}</li>)}
</ul>
}
</section>
<section className="ticket-section">
<h2>Participants <span>{record.participants.length}</span></h2>
{record.participants.length === 0 ? <p className="ticket-muted">No additional participants are recorded.</p> : <ul className="ticket-participant-list">{record.participants.map((subject) => <li key={`${subject.kind}:${subject.id}:${subject.role || ""}`}><strong>{subject.label || subject.id}</strong><small>{humanize(subject.role || subject.kind)}</small></li>)}</ul>}
</section>
<section className="ticket-section" data-interface-id="tickets.section.history" data-help-context-id="tickets.section.history">
<h2>Immutable history <span>{history.length}</span></h2>
{history.length === 0 ? <p className="ticket-muted">History is loading or unavailable.</p> : <ol className="ticket-history-list">{history.map((entry) => <li key={`${entry.revision}:${entry.event_type}`}><div><strong>{humanize(entry.event_type)}</strong><span>Revision {entry.revision} · {formatDateTime(entry.occurred_at)}</span></div><p>{entry.reason}</p></li>)}</ol>}
</section>
{canComment && <section className="ticket-section">
<h2><MessageSquarePlus size={17} /> Add comment</h2>
<form className="ticket-comment-form" onSubmit={(event) => {
event.preventDefault();
@@ -405,12 +508,13 @@ function TicketDetail({ record, availability, canTriage, canAssign, canResolve,
}}>
<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>
{canTriage && <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>
</section>}
{canAdmin && <section className="ticket-destructive-actions" aria-label="Destructive ticket actions"><h2>Administrative removal</h2><p>Deletion removes the ticket from active queues and Search while retaining immutable evidence.</p><Button type="button" variant="danger" onClick={onDelete}><Trash2 size={16} /> Delete ticket</Button></section>}
</article>
);
}
@@ -479,6 +583,109 @@ function AssignmentDialog({ open, record, saving, onClose, onSave }: {
}
function ParticipantsDialog({ open, record, saving, onClose, onSave }: {
open: boolean;
record: TicketRecord | null;
saving: boolean;
onClose: () => void;
onSave: (participants: TicketSubject[], reason: string) => Promise<void>;
}) {
const [participants, setParticipants] = useState<TicketSubject[]>([]);
const [kind, setKind] = useState("account");
const [id, setId] = useState("");
const [label, setLabel] = useState("");
const [role, setRole] = useState("");
const [reason, setReason] = useState("Updated ticket participants.");
useEffect(() => {
if (!open) return;
setParticipants(record?.participants ?? []);
setKind("account");
setId("");
setLabel("");
setRole("");
setReason("Updated ticket participants.");
}, [open, record]);
function addParticipant() {
const cleanId = id.trim();
if (!cleanId || participants.length >= 100) return;
const participant = { kind, id: cleanId, label: label.trim() || null, role: role.trim() || null };
setParticipants((current) => [
...current.filter((item) => !(item.kind === participant.kind && item.id === participant.id && item.role === participant.role)),
participant
]);
setId("");
setLabel("");
setRole("");
}
return <Dialog open={open} title="Manage participants" onClose={onClose} closeDisabled={saving} className="ticket-participants-dialog" footer={<><Button onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="ticket-participants-form" variant="primary" disabled={saving}>Save participants</Button></>}>
<FormLayout id="ticket-participants-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => { event.preventDefault(); void onSave(participants, reason); }}>
<label><FieldLabel>Subject type</FieldLabel><select value={kind} onChange={(event) => setKind(event.target.value)}><option value="account">Account</option><option value="identity">Identity</option><option value="group">Group</option><option value="role">Role</option><option value="function">Function</option><option value="function_assignment">Function assignment</option><option value="organization_unit">Organization unit</option><option value="external">External</option></select></label>
<label><FieldLabel>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>Ticket role</FieldLabel><input value={role} maxLength={80} onChange={(event) => setRole(event.target.value)} /></label>
<div className="wide ticket-dialog-add"><Button type="button" onClick={addParticipant} disabled={!id.trim() || participants.length >= 100}><Plus size={16} /> Add participant</Button></div>
<div className="wide ticket-dialog-list" aria-label="Selected ticket participants">
{participants.length === 0 ? <p className="ticket-muted">No additional participants.</p> : <ul>{participants.map((participant) => <li key={`${participant.kind}:${participant.id}:${participant.role || ""}`}><span><strong>{participant.label || participant.id}</strong><small>{humanize(participant.role || participant.kind)}</small></span><Button type="button" variant="ghost" onClick={() => setParticipants((current) => current.filter((item) => item !== participant))}>Remove</Button></li>)}</ul>}
</div>
<label className="wide"><FieldLabel help="Stored with immutable revision evidence.">Change reason</FieldLabel><input value={reason} required maxLength={1_000} onChange={(event) => setReason(event.target.value)} /></label>
</FormLayout>
</Dialog>;
}
function LinkDialog({ open, saving, onClose, onSave }: {
open: boolean;
saving: boolean;
onClose: () => void;
onSave: (link: TicketLink, reason: string) => Promise<void>;
}) {
const [kind, setKind] = useState("related");
const [ownerModule, setOwnerModule] = useState("");
const [resourceType, setResourceType] = useState("");
const [resourceId, setResourceId] = useState("");
const [relation, setRelation] = useState("related");
const [label, setLabel] = useState("");
const [url, setUrl] = useState("");
const [reason, setReason] = useState("Added a typed ticket reference.");
useEffect(() => {
if (!open) return;
setKind("related");
setOwnerModule("");
setResourceType("");
setResourceId("");
setRelation("related");
setLabel("");
setUrl("");
setReason("Added a typed ticket reference.");
}, [open]);
return <Dialog open={open} title="Add reference or attachment" onClose={onClose} closeDisabled={saving} className="ticket-link-dialog" footer={<><Button onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="ticket-link-form" variant="primary" disabled={saving}>Add reference</Button></>}>
<FormLayout id="ticket-link-form" columns={2} gap="compact" collapseAt="narrow" onSubmit={(event) => {
event.preventDefault();
void onSave({
link_id: crypto.randomUUID(),
kind,
owner_module: ownerModule,
resource_type: resourceType,
resource_id: resourceId,
relation,
label: label || null,
url: url || null,
metadata: {}
}, reason);
}}>
<label><FieldLabel>Reference kind</FieldLabel><select value={kind} onChange={(event) => setKind(event.target.value)}><option value="related">Related record</option><option value="attachment">Attachment</option><option value="file">File</option><option value="project">Project</option><option value="wiki">Wiki</option><option value="asset">Asset</option><option value="facility">Facility</option><option value="form">Form</option><option value="external">External reference</option></select></label>
<label><FieldLabel>Owning module</FieldLabel><input value={ownerModule} required maxLength={100} onChange={(event) => setOwnerModule(event.target.value)} /></label>
<label><FieldLabel>Resource type</FieldLabel><input value={resourceType} required maxLength={100} onChange={(event) => setResourceType(event.target.value)} /></label>
<label><FieldLabel>Resource identifier</FieldLabel><input value={resourceId} required maxLength={255} onChange={(event) => setResourceId(event.target.value)} /></label>
<label><FieldLabel>Relation</FieldLabel><input value={relation} required maxLength={80} onChange={(event) => setRelation(event.target.value)} /></label>
<label><FieldLabel>Display label</FieldLabel><input value={label} maxLength={500} onChange={(event) => setLabel(event.target.value)} /></label>
<label className="wide"><FieldLabel help="Use a relative application path or an explicit http(s) URL.">Open link</FieldLabel><input value={url} maxLength={1_500} onChange={(event) => setUrl(event.target.value)} /></label>
<label className="wide"><FieldLabel help="Stored with immutable revision evidence.">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;
+5 -1
View File
@@ -42,7 +42,11 @@ export const ticketsModule: PlatformWebModule = {
{ id: "tickets.page.queue", moduleId: "tickets", kind: "section", label: "Ticket queue", parentId: "tickets.route.workspace", order: 30 },
{ id: "tickets.page.detail", moduleId: "tickets", kind: "section", label: "Ticket details", parentId: "tickets.route.workspace", order: 40 },
{ id: "tickets.action.report", moduleId: "tickets", kind: "action", label: "Report ticket", parentId: "tickets.page.queue", order: 50 },
{ id: "tickets.action.resolve", moduleId: "tickets", kind: "action", label: "Resolve ticket", parentId: "tickets.page.detail", order: 60 }
{ id: "tickets.action.resolve", moduleId: "tickets", kind: "action", label: "Resolve ticket", parentId: "tickets.page.detail", order: 60 },
{ id: "tickets.section.history", moduleId: "tickets", kind: "section", label: "Immutable ticket history", parentId: "tickets.page.detail", order: 70 },
{ id: "tickets.action.participants", moduleId: "tickets", kind: "action", label: "Manage ticket participants", parentId: "tickets.page.detail", order: 80 },
{ id: "tickets.action.link", moduleId: "tickets", kind: "action", label: "Add or remove ticket reference", parentId: "tickets.page.detail", order: 90 },
{ id: "tickets.action.delete", moduleId: "tickets", kind: "action", label: "Delete ticket", parentId: "tickets.page.detail", order: 100 }
]
};
+98
View File
@@ -172,6 +172,96 @@
color: var(--text-soft);
}
.ticket-link-list li > div,
.ticket-participant-list li,
.ticket-dialog-list li,
.ticket-history-list li > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.ticket-participant-list,
.ticket-dialog-list ul,
.ticket-history-list {
margin: 0;
padding: 0;
list-style: none;
}
.ticket-participant-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.ticket-participant-list li {
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-compact);
background: var(--surface-raised);
}
.ticket-participant-list small,
.ticket-dialog-list small,
.ticket-history-list span {
color: var(--text-soft);
font-size: 0.76rem;
}
.ticket-history-list li {
display: grid;
grid-template-columns: minmax(180px, 0.35fr) minmax(0, 1fr);
gap: 16px;
padding: 10px 2px;
border-bottom: 1px solid var(--border);
}
.ticket-history-list p {
margin: 0;
}
.ticket-dialog-add {
display: flex;
justify-content: flex-end;
}
.ticket-dialog-list {
max-height: 220px;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-compact);
}
.ticket-dialog-list > p {
padding: 10px;
}
.ticket-dialog-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.ticket-destructive-actions {
margin-top: 32px;
padding-top: 18px;
border-top: 1px solid var(--danger, #b42318);
}
.ticket-destructive-actions h2 {
margin: 0 0 6px;
font-size: 0.98rem;
}
.ticket-destructive-actions p {
color: var(--text-soft);
}
.ticket-comment-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
@@ -223,6 +313,10 @@
.ticket-comment-form {
grid-template-columns: 1fr;
}
.ticket-history-list {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
@@ -234,4 +328,8 @@
.ticket-facts {
grid-template-columns: 1fr;
}
.ticket-participant-list {
grid-template-columns: 1fr;
}
}