Integrate formal decisions with cases
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import { Scale } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
i18nMessage,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
recordCaseDecision,
|
||||
type CaseDecisionResult,
|
||||
type CaseRecord
|
||||
} from "../../api/cases";
|
||||
import { CASES_FIELDS_DOCUMENTATION } from "./interfacePatterns";
|
||||
|
||||
|
||||
export default function CaseDecisionDialog({
|
||||
settings,
|
||||
record,
|
||||
open,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
record: CaseRecord;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (result: CaseDecisionResult) => void;
|
||||
}) {
|
||||
const [decisionType, setDecisionType] = useState("");
|
||||
const [effectiveAt, setEffectiveAt] = useState("");
|
||||
const [operativeResult, setOperativeResult] = useState("");
|
||||
const [reasoning, setReasoning] = useState("");
|
||||
const [conditions, setConditions] = useState("");
|
||||
const [changeReason, setChangeReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const idempotencyKey = useRef(crypto.randomUUID());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDecisionType(defaultDecisionType(record));
|
||||
setEffectiveAt(dateTimeLocalValue(new Date()));
|
||||
setOperativeResult("");
|
||||
setReasoning("");
|
||||
setConditions("");
|
||||
setChangeReason("");
|
||||
setError("");
|
||||
setConfirmOpen(false);
|
||||
idempotencyKey.current = crypto.randomUUID();
|
||||
}, [open, record]);
|
||||
|
||||
const disabledReason = useMemo(() => {
|
||||
if (busy) return "The formal Decision is being recorded.";
|
||||
if (
|
||||
!decisionType.trim()
|
||||
|| !effectiveAt
|
||||
|| !operativeResult.trim()
|
||||
|| !reasoning.trim()
|
||||
|| !changeReason.trim()
|
||||
) {
|
||||
return "Complete the Decision type, effective time, result, reasoning, and change reason.";
|
||||
}
|
||||
return undefined;
|
||||
}, [busy, changeReason, decisionType, effectiveAt, operativeResult, reasoning]);
|
||||
|
||||
async function save() {
|
||||
if (disabledReason) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await recordCaseDecision(settings, record.reference.object_id, {
|
||||
expected_case_revision: record.revision,
|
||||
effective_at: new Date(effectiveAt).toISOString(),
|
||||
decision_type: decisionType.trim(),
|
||||
operative_result: operativeResult.trim(),
|
||||
reasoning: reasoning.trim(),
|
||||
conditions: conditions.split("\n").map((item) => item.trim()).filter(Boolean),
|
||||
change_reason: changeReason.trim(),
|
||||
idempotency_key: idempotencyKey.current
|
||||
});
|
||||
setConfirmOpen(false);
|
||||
onSaved(result);
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setConfirmOpen(false);
|
||||
setError(reason instanceof Error ? reason.message : "The formal Decision could not be recorded.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
title={i18nMessage("i18n:govoplan-cases.decision_title", { value0: record.case_number })}
|
||||
onClose={onClose}
|
||||
closeDisabled={busy}
|
||||
portal
|
||||
className="case-decision-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabledReason={disabledReason}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
helpContextId="cases.action.decide"
|
||||
>
|
||||
<Scale size={16} aria-hidden="true" />
|
||||
Record decision
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="case-decision-content" data-help-context-id="cases.decision.editor">
|
||||
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<p className="case-decision-explanation">
|
||||
The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.
|
||||
</p>
|
||||
<div className="case-decision-grid">
|
||||
<FormField label="Decision type" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<input
|
||||
value={decisionType}
|
||||
disabled={busy}
|
||||
maxLength={120}
|
||||
onChange={(event) => setDecisionType(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Effective at" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={effectiveAt}
|
||||
disabled={busy}
|
||||
onChange={(event) => setEffectiveAt(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Operative result" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={operativeResult}
|
||||
disabled={busy}
|
||||
maxLength={20_000}
|
||||
onChange={(event) => setOperativeResult(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Reasoning" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<textarea
|
||||
rows={7}
|
||||
value={reasoning}
|
||||
disabled={busy}
|
||||
maxLength={50_000}
|
||||
onChange={(event) => setReasoning(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Conditions (one per line)" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={conditions}
|
||||
disabled={busy}
|
||||
onChange={(event) => setConditions(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||
<input
|
||||
value={changeReason}
|
||||
disabled={busy}
|
||||
maxLength={1_000}
|
||||
onChange={(event) => setChangeReason(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Record formal Decision"
|
||||
message="Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision."
|
||||
confirmLabel="Record decision"
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
onConfirm={() => void save()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function defaultDecisionType(record: CaseRecord): string {
|
||||
const resultRefs = Array.isArray(record.metadata.result_refs)
|
||||
? record.metadata.result_refs
|
||||
: [];
|
||||
const result = resultRefs.find((item): item is string => typeof item === "string");
|
||||
if (result) return result.replace(/^decision:/, "");
|
||||
return record.case_type_key.replace(/-application$/, "");
|
||||
}
|
||||
|
||||
|
||||
function dateTimeLocalValue(value: Date): string {
|
||||
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
@@ -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)) : "-";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user