feat: implement governed cases workspace

This commit is contained in:
2026-08-01 17:48:24 +02:00
parent 122159de16
commit c7821a5cb0
32 changed files with 5699 additions and 27 deletions
+259
View File
@@ -0,0 +1,259 @@
import { ArrowLeft, Save, Share2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import {
Button,
DismissibleAlert,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
hasScope,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
caseHistory,
caseTimeline,
getCase,
listCaseCatalog,
updateCase,
type CaseCatalog,
type CaseRecord,
type CaseTimelineEntry,
type InstitutionalReference
} from "../../api/cases";
import CaseShareDialog from "./CaseShareDialog";
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
const { caseId = "" } = useParams();
const navigate = useGuardedNavigate();
const [record, setRecord] = useState<CaseRecord | null>(null);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
const [history, setHistory] = useState<CaseRecord[]>([]);
const [timeline, setTimeline] = useState<CaseTimelineEntry[]>([]);
const [title, setTitle] = useState("");
const [status, setStatus] = useState("");
const [changeReason, setChangeReason] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [shareOpen, setShareOpen] = useState(false);
const canUpdate = hasScope(auth, "cases:case:update");
const canClose = hasScope(auth, "cases:case:close");
const canShare = hasScope(auth, "cases:case:share");
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
setError("");
return Promise.all([
getCase(settings, caseId, signal),
listCaseCatalog(settings, signal),
caseHistory(settings, caseId, signal),
caseTimeline(settings, caseId, signal)
]).
then(([nextRecord, nextCatalog, nextHistory, nextTimeline]) => {
setRecord(nextRecord);
setCatalog(nextCatalog);
setHistory(nextHistory.revisions);
setTimeline(nextTimeline.entries);
setTitle(nextRecord.title);
setStatus(nextRecord.status_key);
setChangeReason("");
}).
finally(() => setLoading(false));
}, [caseId, settings]);
useEffect(() => {
const controller = new AbortController();
load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Case could not be loaded.");
}
});
return () => controller.abort();
}, [load]);
const statuses = useMemo(() => {
const type = catalog.types.find((item) => item.type_key === record?.case_type_key);
const allowed = new Set(type?.allowed_status_keys ?? []);
return catalog.statuses.filter((item) =>
(allowed.size === 0 || allowed.has(item.status_key))
&& (canClose || !item.terminal || item.status_key === record?.status_key)
);
}, [canClose, catalog, record]);
const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
async function save() {
if (!record || !changed || !changeReason.trim()) return;
setSaving(true);
setError("");
try {
await updateCase(settings, caseId, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: crypto.randomUUID(),
...(title.trim() !== record.title ? { title: title.trim() } : {}),
...(status !== record.status_key ? { status_key: status } : {})
});
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Case could not be saved.");
} finally {
setSaving(false);
}
}
return (
<main className="cases-page">
<div className="case-detail-shell">
<div className="case-detail-toolbar">
<button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}>
<ArrowLeft size={16} aria-hidden="true" />
Cases
</button>
{record && <span>{record.case_number}</span>}
{record && canShare ? (
<IconButton
label="Manage case access"
icon={<Share2 size={16} />}
className="case-share-button"
onClick={() => setShareOpen(true)}
/>
) : null}
</div>
<PageScrollViewport className="case-detail-viewport">
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading case" />}
{!loading && record &&
<div className="case-detail-content">
<section className="case-detail-main">
<div className="case-detail-title-row">
<div>
<span className="case-detail-eyebrow">{humanize(record.case_type_key)}</span>
<h1>{record.title}</h1>
</div>
<StatusBadge status={record.closed_at ? "inactive" : "active"} label={humanize(record.status_key)} />
</div>
{canUpdate &&
<div className="case-edit-panel">
<label>
<span>Title</span>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
</label>
<label>
<span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
{statuses.map((item) => <option key={item.status_key} value={item.status_key}>{item.label}</option>)}
</select>
</label>
<label className="case-change-reason">
<span>Change reason</span>
<input value={changeReason} onChange={(event) => setChangeReason(event.target.value)} />
</label>
<Button
variant="primary"
disabled={!changed || !title.trim() || !changeReason.trim() || saving}
onClick={save}>
<Save size={16} aria-hidden="true" />
{saving ? "Saving" : "Save"}
</Button>
</div>
}
<div className="case-facts">
<Fact label="Opened" value={formatDateTime(record.opened_at)} />
<Fact label="Deadline" value={formatDateTime(record.deadline_at)} />
<Fact label="Revision" value={String(record.revision)} />
<Fact label="Last change" value={record.change_reason} />
</div>
<ReferenceSection title="Parties" references={record.party_refs} />
<ReferenceSection title="Assignments" references={record.assignment_refs} />
<ReferenceSection title="Decisions" references={record.decision_refs} />
<ReferenceSection title="Records" references={record.record_refs} />
</section>
<aside className="case-detail-aside">
<section>
<h2>Timeline</h2>
<ol className="case-timeline">
{timeline.map((entry) =>
<li key={entry.event_id}>
<strong>{humanize(entry.event_type)}</strong>
<span>{entry.summary}</span>
<time>{formatDateTime(entry.occurred_at)}</time>
</li>
)}
</ol>
</section>
<section>
<h2>History</h2>
<div className="case-history-list">
{history.map((revision) =>
<div key={revision.revision}>
<strong>Revision {revision.revision}</strong>
<span>{revision.change_reason}</span>
<time>{formatDateTime(revision.recorded_at)}</time>
</div>
)}
</div>
</section>
</aside>
</div>
}
</PageScrollViewport>
</div>
{record ? (
<CaseShareDialog
settings={settings}
record={record}
open={shareOpen}
onClose={() => setShareOpen(false)}
onSaved={(saved) => {
setRecord(saved);
void load().catch((reason) => {
setError(reason instanceof Error ? reason.message : "Case could not be reloaded.");
});
}}
/>
) : null}
</main>
);
}
function Fact({ label, value }: { label: string; value: string }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
function ReferenceSection({ title, references }: { title: string; references: InstitutionalReference[] }) {
if (references.length === 0) return null;
return (
<section className="case-reference-section">
<h2>{title}</h2>
<div className="case-reference-list">
{references.map((reference) =>
<span key={`${reference.owner_module}:${reference.object_id}:${reference.version ?? "current"}`}>
<strong>{humanize(reference.kind)}</strong>
{reference.object_id}
</span>
)}
</div>
</section>
);
}
function formatDateTime(value?: string | null): string {
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "-";
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}