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(null); const [catalog, setCatalog] = useState({ statuses: [], types: [] }); const [history, setHistory] = useState([]); const [timeline, setTimeline] = useState([]); const [decisions, setDecisions] = useState([]); 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 { 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 { 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 (
{record && {record.case_number}} {record ? (
{decisionsAvailable ? ( } disabledReason={decisionDisabledReason} onClick={() => setDecisionOpen(true)} helpContextId="cases.action.decide" /> ) : null} {canFileRecords ? ( } onClick={() => navigate(recordFilingPath(record.reference, record.case_number, "cases", "case_revision"))} helpContextId="records.action.file" /> ) : null} } disabledReason={!canShare ? CASES_I18N.shareReason : undefined} onClick={() => setShareOpen(true)} />
) : null}
{error && {error} } {loading && } {!loading && record &&
{humanize(record.case_type_key)}

{record.title}

{!canUpdate ? ( ) : null} {canUpdate && !canClose ? ( ) : null} {canUpdate &&
setTitle(event.target.value)} />
setChangeReason(event.target.value)} />
}
void unlinkEvidence(evidence)} /> {canReadDecisions ? ( navigate(recordFilingPath( decision.reference, `${humanize(decision.decision_type)} Decision`, "decisions", "decision_revision" ))} /> ) : ( )}
}
{record ? ( setShareOpen(false)} onSaved={(saved) => { setRecord(saved); void load().catch((reason) => { setError(reason instanceof Error ? reason.message : "Case could not be reloaded."); }); }} /> ) : null} {record ? ( 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}
); } function Fact({ label, value }: { label: string; value: string }) { return
{label}{value}
; } function ReferenceSection({ title, references }: { title: string; references: InstitutionalReference[] }) { if (references.length === 0) return null; return (

{title}

{references.map((reference) => {humanize(reference.kind)} {reference.object_id} )}
); } function EvidenceSection({ references, providers, canUnlink, onUnlink }: { references: EvidenceReference[]; providers: Record; canUnlink: boolean; onUnlink: (reference: EvidenceReference) => void; }) { return (

Linked files, mail, and campaigns

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.

{references.length ? (
{references.map((reference) => { const source = linkedEvidenceSource(reference); const available = source ? providers[source.owner_module] !== false : providers[reference.owner_module] !== false; return ( {humanize(source?.kind ?? reference.kind)} {reference.owner_module} · version {reference.version ?? "not pinned"} {source && available ? ( ) : source ? ( Owner module unavailable; the reference remains in Case history. ) : null} {source ? ( ) : null} ); })}
) : (

No owner references are linked.

)}
); } 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; 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 (

Decisions

{decisions.map((decision) => (
{humanize(decision.decision_type)} Revision {decision.reference.version ?? decision.temporal.revision}
{canFile ? ( } onClick={() => onFile(decision)} helpContextId="records.action.file" /> ) : null}
{decision.operative_result ?

Operative result{decision.operative_result}

: null} {decision.reasoning ?

Reasoning{decision.reasoning}

: null} {!decision.operative_result && !decision.reasoning ? (

Protected Decision details require the sensitive-read permission.

) : null} {decision.conditions.length > 0 ? (
    {decision.conditions.map((condition) =>
  • {condition}
  • )}
) : null}
))}
); } 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()); }