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