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);
|
||||
}
|
||||
Reference in New Issue
Block a user