feat(tickets): complete governed ticket workspace
This commit is contained in:
@@ -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()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user