Integrate formal decisions with cases

This commit is contained in:
2026-08-06 12:42:20 +02:00
parent 2f030e56a9
commit 23f5091a2b
17 changed files with 2048 additions and 23 deletions
+150 -12
View File
@@ -1,4 +1,4 @@
import { ArrowLeft, Save, Share2 } from "lucide-react";
import { Archive, ArrowLeft, Save, Scale, Share2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router";
import {
@@ -12,6 +12,7 @@ import {
PageScrollViewport,
StatusBadge,
hasScope,
usePlatformModuleInstalled,
useGuardedNavigate,
useUnsavedDraftGuard,
type PlatformRouteContext
@@ -20,14 +21,17 @@ import {
caseHistory,
caseTimeline,
getCase,
listCaseDecisions,
listCaseCatalog,
updateCase,
type CaseCatalog,
type CaseRecord,
type CaseTimelineEntry,
type FormalDecision,
type InstitutionalReference
} from "../../api/cases";
import CaseShareDialog from "./CaseShareDialog";
import CaseDecisionDialog from "./CaseDecisionDialog";
import {
CASES_DOCUMENTATION,
CASES_FIELDS_DOCUMENTATION,
@@ -42,6 +46,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
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("");
@@ -49,10 +54,26 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [shareOpen, setShareOpen] = useState(false);
const [decisionOpen, setDecisionOpen] = 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 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 load = useCallback((signal?: AbortSignal) => {
setLoading(true);
@@ -61,19 +82,23 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
getCase(settings, caseId, signal),
listCaseCatalog(settings, signal),
caseHistory(settings, caseId, signal),
caseTimeline(settings, caseId, signal)
caseTimeline(settings, caseId, signal),
canReadDecisions
? listCaseDecisions(settings, caseId, signal)
: Promise.resolve({ decisions: [] as FormalDecision[] })
]).
then(([nextRecord, nextCatalog, nextHistory, nextTimeline]) => {
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));
}, [caseId, settings]);
}, [canReadDecisions, caseId, settings]);
useEffect(() => {
const controller = new AbortController();
@@ -162,13 +187,33 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
Cases
</button>
{record && <span>{record.case_number}</span>}
{record ? <IconButton
label="Manage case access"
icon={<Share2 size={16} />}
className="case-share-button"
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
onClick={() => setShareOpen(true)}
/> : null}
{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} />
</div>
<PageScrollViewport className="case-detail-viewport">
@@ -250,7 +295,20 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<ReferenceSection title="Parties" references={record.party_refs} />
<ReferenceSection title="Assignments" references={record.assignment_refs} />
<ReferenceSection title="Decisions" references={record.decision_refs} />
{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>
@@ -298,6 +356,24 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
}}
/>
) : 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>
);
}
@@ -323,6 +399,68 @@ function ReferenceSection({ title, references }: { title: string; references: In
);
}
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)) : "-";
}