Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
213 lines
7.1 KiB
TypeScript
213 lines
7.1 KiB
TypeScript
import { Scale } from "lucide-react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { FormGrid,
|
|
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,
|
|
purpose,
|
|
open,
|
|
onClose,
|
|
onSaved
|
|
}: {
|
|
settings: ApiSettings;
|
|
record: CaseRecord;
|
|
purpose: string;
|
|
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,
|
|
purpose
|
|
});
|
|
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 })}
|
|
titleHelp={<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />}
|
|
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">
|
|
{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>
|
|
<FormGrid columns={2} gap="small" collapseAt="workspace" 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>
|
|
</FormGrid>
|
|
<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);
|
|
}
|