Files
govoplan-cases/webui/src/features/cases/CaseDetailPage.tsx
T
zemion f236034e88
Module Package Release / publish-packages (push) Successful in 11s
feat(cases): link provider-owned evidence references
2026-08-21 16:10:48 +02:00

678 lines
25 KiB
TypeScript

import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router";
import { ActionToolbar,
ActionBlockerHint,
Button,
DocumentationHelpLink,
DismissibleAlert,
FormField,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
QUICK_ACCESS_RESULT_EVENT,
hasScope,
usePlatformModuleInstalled,
useGuardedNavigate,
useRegisterActiveObject,
useUnsavedDraftGuard,
type PlatformRouteContext,
type QuickAccessLaunchContext,
type QuickAccessResult
} from "@govoplan/core-webui";
import {
caseHistory,
caseTimeline,
getCase,
linkCaseEvidence,
listCaseDecisions,
listCaseCatalog,
updateCase,
unlinkCaseEvidence,
type CaseCatalog,
type CaseRecord,
type CaseTimelineEntry,
type EvidenceReference,
type FormalDecision,
type InstitutionalReference
} from "../../api/cases";
import CaseShareDialog from "./CaseShareDialog";
import CaseDecisionDialog from "./CaseDecisionDialog";
import {
CASES_DOCUMENTATION,
CASES_FIELDS_DOCUMENTATION,
CASES_I18N
} from "./interfacePatterns";
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 [decisions, setDecisions] = useState<FormalDecision[]>([]);
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 [decisionOpen, setDecisionOpen] = useState(false);
const [linkingEvidence, setLinkingEvidence] = useState(false);
const idempotencyKey = useRef(crypto.randomUUID());
const canUpdate = hasScope(auth, "cases:case:update");
const canClose = hasScope(auth, "cases:case:close");
const canShare = hasScope(auth, "cases:case:share");
const decisionsAvailable = usePlatformModuleInstalled("decisions");
const mandatesAvailable = usePlatformModuleInstalled("mandates");
const accessAvailable = usePlatformModuleInstalled("access");
const recordsAvailable = usePlatformModuleInstalled("records");
const filesAvailable = usePlatformModuleInstalled("files");
const mailAvailable = usePlatformModuleInstalled("mail");
const campaignsAvailable = usePlatformModuleInstalled("campaigns");
const canReadDecisions = decisionsAvailable && hasScope(auth, "decisions:decision:read");
const decisionDisabledReason = !canUpdate
? "Your account may not update this Case."
: !hasScope(auth, "decisions:decision:write")
? "Your account may not record formal Decisions."
: !accessAvailable
? "Enable Access to verify the acting assignment."
: !mandatesAvailable
? "Enable Mandates to verify formal authority."
: undefined;
const canFileRecords = recordsAvailable && hasScope(auth, "records:workspace:write");
const activeCase = useMemo(() => record ? {
ownerModule: record.reference.owner_module,
kind: record.reference.kind,
objectId: record.reference.object_id,
tenantId: record.reference.tenant_id,
label: `${record.case_number} · ${record.title}`,
version: record.reference.version ?? String(record.revision),
path: `/cases/${encodeURIComponent(record.reference.object_id)}`
} : null, [record]);
useRegisterActiveObject(activeCase);
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),
canReadDecisions
? listCaseDecisions(settings, caseId, signal)
: Promise.resolve({ decisions: [] as FormalDecision[] })
]).
then(([nextRecord, nextCatalog, nextHistory, nextTimeline, nextDecisions]) => {
setRecord(nextRecord);
setCatalog(nextCatalog);
setHistory(nextHistory.revisions);
setTimeline(nextTimeline.entries);
setDecisions(nextDecisions.decisions);
setTitle(nextRecord.title);
setStatus(nextRecord.status_key);
setChangeReason("");
}).
finally(() => setLoading(false));
}, [canReadDecisions, 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));
const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim()));
useEffect(() => {
if (!record || !canUpdate) return;
const receiveReference = (event: Event) => {
const detail = (event as CustomEvent<{
launchContext?: QuickAccessLaunchContext;
result?: QuickAccessResult;
}>).detail;
const sourceCase = detail?.launchContext?.activeObject;
const result = detail?.result;
if (
!sourceCase
|| sourceCase.ownerModule !== "cases"
|| sourceCase.kind !== "case"
|| sourceCase.objectId !== record.reference.object_id
|| sourceCase.tenantId !== record.reference.tenant_id
|| result?.outcome !== "completed"
|| result.action !== "selected"
|| !result.reference
) return;
if (draftDirty) {
setError(
"Save or discard the current Case edits before linking the selected reference."
);
return;
}
const reference = result.reference;
if (!reference.version || !reference.path) {
setError("The selected owner reference is not an exact versioned link.");
return;
}
setLinkingEvidence(true);
setError("");
void linkCaseEvidence(settings, caseId, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
idempotency_key: crypto.randomUUID(),
reference: {
owner_module: reference.ownerModule,
kind: reference.kind,
object_id: reference.objectId,
tenant_id: reference.tenantId,
version: reference.version,
path: reference.path
}
}).then((saved) => {
setRecord(saved);
setTitle(saved.title);
setStatus(saved.status_key);
return load();
}).catch((reason) => {
setError(reason instanceof Error
? reason.message
: "The selected reference could not be linked to this Case.");
}).finally(() => setLinkingEvidence(false));
};
window.addEventListener(QUICK_ACCESS_RESULT_EVENT, receiveReference);
return () => window.removeEventListener(
QUICK_ACCESS_RESULT_EVENT,
receiveReference
);
}, [canUpdate, caseId, draftDirty, load, record, settings]);
function discardDraft() {
if (!record) return;
setTitle(record.title);
setStatus(record.status_key);
setChangeReason("");
}
async function save(): Promise<boolean> {
if (!record || !changed || !title.trim() || !changeReason.trim()) return false;
setSaving(true);
setError("");
try {
const saved = await updateCase(settings, caseId, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current,
...(title.trim() !== record.title ? { title: title.trim() } : {}),
...(status !== record.status_key ? { status_key: status } : {})
});
setRecord(saved);
setTitle(saved.title);
setStatus(saved.status_key);
setChangeReason("");
idempotencyKey.current = crypto.randomUUID();
try {
await load();
} catch (reloadError) {
setError(reloadError instanceof Error
? `The case was saved, but its history could not be refreshed. ${reloadError.message}`
: "The case was saved, but its history could not be refreshed.");
}
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Case could not be saved.");
return false;
} finally {
setSaving(false);
}
}
async function unlinkEvidence(evidence: EvidenceReference): Promise<void> {
if (!record || draftDirty || linkingEvidence) return;
if (!window.confirm("Remove this owner reference from the Case? The owner data is not deleted.")) return;
setLinkingEvidence(true);
setError("");
try {
const saved = await unlinkCaseEvidence(
settings,
caseId,
evidence.evidence_id,
{
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
idempotency_key: crypto.randomUUID()
}
);
setRecord(saved);
setTitle(saved.title);
setStatus(saved.status_key);
await load();
} catch (reason) {
setError(reason instanceof Error
? reason.message
: "The owner reference could not be removed from this Case.");
} finally {
setLinkingEvidence(false);
}
}
useUnsavedDraftGuard({
dirty: draftDirty,
title: "i18n:govoplan-cases.unsaved_title",
message: "i18n:govoplan-cases.unsaved_message",
onSave: save,
onDiscard: discardDraft
});
const saveDisabledReason = saving
? CASES_I18N.saving
: !changed
? CASES_I18N.noChanges
: !title.trim() || !changeReason.trim()
? CASES_I18N.incomplete
: undefined;
return (
<main className="cases-page">
<div className="case-detail-shell">
<ActionToolbar 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 ? (
<div className="case-detail-actions">
{decisionsAvailable ? (
<IconButton
label="Record formal decision"
icon={<Scale size={16} />}
disabledReason={decisionDisabledReason}
onClick={() => setDecisionOpen(true)}
helpContextId="cases.action.decide"
/>
) : null}
{canFileRecords ? (
<IconButton
label="File Case in eAkte"
icon={<Archive size={16} />}
onClick={() => navigate(recordFilingPath(record.reference, record.case_number, "cases", "case_revision"))}
helpContextId="records.action.file"
/>
) : null}
<IconButton
label="Manage case access"
icon={<Share2 size={16} />}
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
onClick={() => setShareOpen(true)}
/>
</div>
) : null}
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
</ActionToolbar>
<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 ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "Case editing is read-only",
details: CASES_I18N.updateReason,
requiredAction: "Ask a case manager to make the required lifecycle change.",
actor: "A user with the Cases update permission",
target: "Case role or object access assignment"
}}
documentation={CASES_FIELDS_DOCUMENTATION}
/>
) : null}
{canUpdate && !canClose ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "Terminal case states are unavailable",
details: CASES_I18N.closeReason,
requiredAction: "Ask a case closer to complete the lifecycle transition.",
actor: "A user with the Cases close permission",
target: "Case role assignment"
}}
documentation={CASES_FIELDS_DOCUMENTATION}
/>
) : null}
{canUpdate &&
<div className="case-edit-panel">
<FormField label="Title" documentation={CASES_FIELDS_DOCUMENTATION}>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
</FormField>
<FormField label="Status" documentation={CASES_FIELDS_DOCUMENTATION}>
<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>
</FormField>
<div className="case-change-reason">
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
<input value={changeReason} onChange={(event) => setChangeReason(event.target.value)} />
</FormField>
</div>
<Button
variant="primary"
disabledReason={saveDisabledReason}
onClick={() => void 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} />
<EvidenceSection
references={record.evidence_refs}
providers={{
files: filesAvailable,
mail: mailAvailable,
campaigns: campaignsAvailable
}}
canUnlink={canUpdate && !draftDirty && !linkingEvidence}
onUnlink={(evidence) => void unlinkEvidence(evidence)}
/>
{canReadDecisions ? (
<DecisionSection
decisions={decisions}
canFile={canFileRecords}
onFile={(decision) => navigate(recordFilingPath(
decision.reference,
`${humanize(decision.decision_type)} Decision`,
"decisions",
"decision_revision"
))}
/>
) : (
<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}
{record ? (
<CaseDecisionDialog
settings={settings}
record={record}
open={decisionOpen}
onClose={() => setDecisionOpen(false)}
onSaved={(result) => {
setRecord(result.case);
setDecisions((current) => [
result.decision,
...current.filter((item) => item.reference.object_id !== result.decision.reference.object_id)
]);
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 EvidenceSection({
references,
providers,
canUnlink,
onUnlink
}: {
references: EvidenceReference[];
providers: Record<string, boolean>;
canUnlink: boolean;
onUnlink: (reference: EvidenceReference) => void;
}) {
return (
<section className="case-reference-section case-evidence-section">
<h2>Linked files, mail, and campaigns</h2>
<p className="muted small-note">
Select an authorized exact item in Quick Access to link it. Cases stores
only the owner reference; the owning module checks access again when opened.
</p>
{references.length ? (
<div className="case-reference-list">
{references.map((reference) => {
const source = linkedEvidenceSource(reference);
const available = source
? providers[source.owner_module] !== false
: providers[reference.owner_module] !== false;
return (
<span key={`${reference.owner_module}:${reference.evidence_id}:${reference.version ?? "current"}`}>
<strong>{humanize(source?.kind ?? reference.kind)}</strong>
<small>{reference.owner_module} · version {reference.version ?? "not pinned"}</small>
{source && available ? (
<a href={source.path} className="btn btn-secondary">
<ExternalLink size={15} aria-hidden="true" /> Open in owner
</a>
) : source ? (
<small>Owner module unavailable; the reference remains in Case history.</small>
) : null}
{source ? (
<Button
variant="danger"
disabled={!canUnlink}
disabledReason={!canUnlink
? "Save or discard Case edits before removing a link."
: undefined}
onClick={() => onUnlink(reference)}
>
<Unlink size={15} aria-hidden="true" /> Unlink
</Button>
) : null}
</span>
);
})}
</div>
) : (
<p className="muted">No owner references are linked.</p>
)}
</section>
);
}
function linkedEvidenceSource(reference: EvidenceReference): {
owner_module: string;
kind: string;
path: string;
} | null {
if (!reference.source_ref) return null;
try {
const value = JSON.parse(reference.source_ref) as Record<string, unknown>;
const owner = String(value.owner_module ?? "");
const kind = String(value.kind ?? "");
const path = String(value.path ?? "");
if (
value.contract !== "cases.quick-access-reference.v1"
|| !owner
|| !kind
|| !path.startsWith("/")
|| path.startsWith("//")
) return null;
return { owner_module: owner, kind, path };
} catch {
return null;
}
}
function DecisionSection({
decisions,
canFile,
onFile
}: {
decisions: FormalDecision[];
canFile: boolean;
onFile: (decision: FormalDecision) => void;
}) {
if (decisions.length === 0) return null;
return (
<section className="case-reference-section">
<h2>Decisions</h2>
<div className="case-decision-list">
{decisions.map((decision) => (
<article key={`${decision.reference.object_id}:${decision.reference.version ?? "current"}`}>
<div className="case-decision-heading">
<div>
<strong>{humanize(decision.decision_type)}</strong>
<span>Revision {decision.reference.version ?? decision.temporal.revision}</span>
</div>
<StatusBadge status={decision.state === "effective" || decision.state === "decided" ? "active" : "inactive"} label={humanize(decision.state)} />
{canFile ? (
<IconButton
label="File Decision in eAkte"
icon={<Archive size={16} />}
onClick={() => onFile(decision)}
helpContextId="records.action.file"
/>
) : null}
</div>
{decision.operative_result ? <p><strong>Operative result</strong>{decision.operative_result}</p> : null}
{decision.reasoning ? <p><strong>Reasoning</strong>{decision.reasoning}</p> : null}
{!decision.operative_result && !decision.reasoning ? (
<p className="case-decision-protected">Protected Decision details require the sensitive-read permission.</p>
) : null}
{decision.conditions.length > 0 ? (
<ul>{decision.conditions.map((condition) => <li key={condition}>{condition}</li>)}</ul>
) : null}
</article>
))}
</div>
</section>
);
}
function recordFilingPath(
reference: InstitutionalReference,
label: string,
sourceModule: string,
resourceType: string
): string {
const query = new URLSearchParams({
sourceModule,
resourceType,
resourceId: reference.object_id,
sourceRevision: reference.version ?? "",
sourceLabel: label
});
return `/records?${query.toString()}`;
}
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());
}