feat(tickets): deliver canonical operational lifecycle
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@govoplan/tickets-webui",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/tickets.css": "./src/styles/tickets.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.30",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
|
||||
const page = fs.readFileSync("src/features/tickets/TicketsPage.tsx", "utf8");
|
||||
const styles = fs.readFileSync("src/styles/tickets.css", "utf8");
|
||||
|
||||
assert.ok(page.includes("WorkspaceActionBar"), "Tickets uses the semantic shared action bar");
|
||||
assert.ok(page.includes("refreshable"), "The refreshable Tickets workspace exposes Reload");
|
||||
assert.ok(page.includes("DocumentationHelpLink"), "Tickets exposes contextual documentation");
|
||||
assert.ok(page.includes("PageScrollViewport"), "Tickets owns bounded queue and detail scrolling");
|
||||
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("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");
|
||||
|
||||
console.log("Tickets interface pattern contract passed.");
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
apiFetch,
|
||||
apiPath,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
|
||||
export type TicketSubject = {
|
||||
kind: string;
|
||||
id: string;
|
||||
label?: string | null;
|
||||
role?: string | null;
|
||||
};
|
||||
|
||||
export type TicketLink = {
|
||||
link_id: string;
|
||||
kind: string;
|
||||
owner_module: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
relation: string;
|
||||
label?: string | null;
|
||||
url?: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TicketRecord = {
|
||||
tenant_id: string;
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
revision: number;
|
||||
ticket_type: "request" | "incident" | "problem" | "report";
|
||||
priority: "low" | "normal" | "high" | "urgent";
|
||||
status: string;
|
||||
title: string;
|
||||
description: string;
|
||||
visibility: "tenant" | "restricted";
|
||||
queue_ref?: string | null;
|
||||
assignee?: TicketSubject | null;
|
||||
reporter?: TicketSubject | null;
|
||||
requester?: TicketSubject | null;
|
||||
participants: TicketSubject[];
|
||||
links: TicketLink[];
|
||||
service_target_at?: string | null;
|
||||
received_at: string;
|
||||
recorded_at: string;
|
||||
resolved_at?: string | null;
|
||||
resolution_summary?: string | null;
|
||||
deleted_at?: string | null;
|
||||
change_reason: string;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TicketListResponse = {
|
||||
tickets: TicketRecord[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type TicketAvailability = {
|
||||
routing: { available: boolean; provider?: string | null };
|
||||
case_escalation: { available: boolean; provider?: string | null };
|
||||
modules: Record<string, boolean>;
|
||||
consequences: Record<string, string>;
|
||||
};
|
||||
|
||||
export function listTickets(
|
||||
settings: ApiSettings,
|
||||
options: { statuses?: string[]; priorities?: string[]; ticketTypes?: string[]; queueRef?: string; query?: string; limit?: number },
|
||||
signal?: AbortSignal
|
||||
): Promise<TicketListResponse> {
|
||||
return apiFetch(settings, apiPath("/api/v1/tickets", {
|
||||
status: options.statuses,
|
||||
priority: options.priorities,
|
||||
ticket_type: options.ticketTypes,
|
||||
queue_ref: options.queueRef,
|
||||
query: options.query,
|
||||
limit: options.limit
|
||||
}), { signal });
|
||||
}
|
||||
|
||||
export function getTicketAvailability(settings: ApiSettings, signal?: AbortSignal): Promise<TicketAvailability> {
|
||||
return apiFetch(settings, "/api/v1/tickets/availability", { signal });
|
||||
}
|
||||
|
||||
export function createTicket(settings: ApiSettings, record: TicketRecord): Promise<TicketRecord> {
|
||||
return apiFetch(settings, "/api/v1/tickets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ record, idempotency_key: crypto.randomUUID() })
|
||||
});
|
||||
}
|
||||
|
||||
function mutation(record: TicketRecord, changeReason: string) {
|
||||
return {
|
||||
expected_revision: record.revision,
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: changeReason,
|
||||
idempotency_key: crypto.randomUUID()
|
||||
};
|
||||
}
|
||||
|
||||
export function triageTicket(settings: ApiSettings, record: TicketRecord, changes: Record<string, unknown>, changeReason: string): Promise<TicketRecord> {
|
||||
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/triage`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ ...mutation(record, changeReason), changes })
|
||||
});
|
||||
}
|
||||
|
||||
export function assignTicket(settings: ApiSettings, record: TicketRecord, assignee: TicketSubject | null, changeReason: string): Promise<TicketRecord> {
|
||||
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/assignment`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...mutation(record, changeReason), assignee })
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTicket(
|
||||
settings: ApiSettings,
|
||||
record: TicketRecord,
|
||||
targetStatus: string,
|
||||
resolutionSummary: string | null,
|
||||
changeReason: string
|
||||
): Promise<TicketRecord> {
|
||||
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/resolution`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...mutation(record, changeReason), target_status: targetStatus, resolution_summary: resolutionSummary })
|
||||
});
|
||||
}
|
||||
|
||||
export function addTicketComment(settings: ApiSettings, record: TicketRecord, body: string, visibility: "internal" | "external") {
|
||||
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
expected_revision: record.revision,
|
||||
comment_id: crypto.randomUUID(),
|
||||
body,
|
||||
visibility,
|
||||
recorded_at: new Date().toISOString(),
|
||||
idempotency_key: crypto.randomUUID()
|
||||
})
|
||||
}) as Promise<{ ticket: TicketRecord; comment: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
export function escalateTicket(settings: ApiSettings, record: TicketRecord, caseTypeKey: string, handoffNote: string) {
|
||||
return apiFetch(settings, `/api/v1/tickets/${encodeURIComponent(record.ticket_id)}/case-escalations`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
expected_revision: record.revision,
|
||||
case_type_key: caseTypeKey,
|
||||
handoff_note: handoffNote || null,
|
||||
occurred_at: new Date().toISOString(),
|
||||
idempotency_key: crypto.randomUUID()
|
||||
})
|
||||
}) as Promise<{ ticket: TicketRecord; escalation: Record<string, unknown> }>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ticketsModule as default, ticketsModule } from "./module";
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import "./styles/tickets.css";
|
||||
|
||||
|
||||
const TicketsPage = lazy(() => import("./features/tickets/TicketsPage"));
|
||||
|
||||
export const ticketsModule: PlatformWebModule = {
|
||||
id: "tickets",
|
||||
label: "Tickets",
|
||||
version: "0.1.20",
|
||||
optionalDependencies: [
|
||||
"cases",
|
||||
"helpdesk",
|
||||
"projects",
|
||||
"wiki",
|
||||
"files",
|
||||
"search"
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
path: "/tickets",
|
||||
anyOf: ["tickets:ticket:read"],
|
||||
order: 22,
|
||||
surfaceId: "tickets.route.workspace",
|
||||
render: (context) => createElement(TicketsPage, context)
|
||||
}
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/tickets",
|
||||
label: "Tickets",
|
||||
iconName: "ticket-check",
|
||||
anyOf: ["tickets:ticket:read"],
|
||||
order: 22,
|
||||
surfaceId: "tickets.navigation"
|
||||
}
|
||||
],
|
||||
viewSurfaces: [
|
||||
{ id: "tickets.navigation", moduleId: "tickets", kind: "navigation", label: "Tickets navigation", order: 10 },
|
||||
{ id: "tickets.route.workspace", moduleId: "tickets", kind: "route", label: "Tickets workspace", order: 20 },
|
||||
{ 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 }
|
||||
]
|
||||
};
|
||||
|
||||
export default ticketsModule;
|
||||
@@ -0,0 +1,237 @@
|
||||
.tickets-page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tickets-search {
|
||||
flex: 1 1 540px;
|
||||
}
|
||||
|
||||
.tickets-status-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.tickets-status-filter > span,
|
||||
.tickets-count {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tickets-count {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tickets-availability {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-subtle, var(--surface));
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.tickets-workspace {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
grid-template-columns: minmax(390px, 42%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.tickets-list-viewport,
|
||||
.ticket-detail-viewport {
|
||||
min-height: 0;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.tickets-list-viewport {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface-subtle, var(--surface));
|
||||
}
|
||||
|
||||
.ticket-list-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ticket-detail {
|
||||
width: min(100%, 980px);
|
||||
margin: 0 auto;
|
||||
padding: 4px 8px 32px;
|
||||
}
|
||||
|
||||
.ticket-detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ticket-detail-header h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.ticket-eyebrow,
|
||||
.ticket-muted {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ticket-detail-badges,
|
||||
.ticket-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.ticket-detail-actions {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ticket-description {
|
||||
max-width: 78ch;
|
||||
margin: 18px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ticket-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-compact);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.ticket-facts > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 11px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.ticket-facts span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.ticket-facts strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.ticket-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.ticket-section h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.98rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.ticket-section h2 span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ticket-link-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ticket-link-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 9px 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ticket-link-list small {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.ticket-comment-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ticket-comment-form textarea {
|
||||
grid-row: span 2;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ticket-editor-dialog {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.ticket-editor-form label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.tickets-workspace {
|
||||
grid-template-columns: minmax(330px, 46%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ticket-facts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.tickets-count {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.tickets-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tickets-list-viewport {
|
||||
max-height: 42vh;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ticket-comment-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.ticket-detail-header,
|
||||
.ticket-link-list li {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ticket-facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user