678 lines
25 KiB
TypeScript
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());
|
|
}
|