Files
govoplan-cases/webui/src/features/cases/CaseDecisionDialog.tsx
T
zemion 0e03c3e77e fix(ui): align contextual documentation with headings
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.
2026-09-09 02:03:29 +02:00

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);
}