Migrate Cases interface patterns

This commit is contained in:
2026-08-03 14:43:37 +02:00
parent c7821a5cb0
commit 43b4cc8b86
10 changed files with 639 additions and 48 deletions
+94 -15
View File
@@ -1,13 +1,18 @@
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FormField,
IconButton,
ReferenceSelect,
ToggleSwitch,
i18nMessage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
@@ -16,6 +21,10 @@ import {
type CaseGrant,
type CaseRecord
} from "../../api/cases";
import {
CASES_FIELDS_DOCUMENTATION,
CASES_I18N
} from "./interfacePatterns";
type TargetType = "user" | "group";
@@ -42,6 +51,9 @@ export default function CaseShareDialog({
const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmOpen, setConfirmOpen] = useState(false);
const idempotencyKey = useRef(crypto.randomUUID());
const { requestDiscard } = useUnsavedChanges();
const targetProvider = useMemo(
() => caseShareTargetProvider(settings, record.reference.object_id, targetType),
[record.reference.object_id, settings, targetType]
@@ -51,14 +63,28 @@ export default function CaseShareDialog({
if (!open) return;
setRestricted(record.access_mode === "restricted");
setGrants(record.access_grants);
setTargetType("user");
setTargetId("");
setPermission("read");
setChangeReason("");
setError("");
setConfirmOpen(false);
idempotencyKey.current = crypto.randomUUID();
}, [open, record]);
const changed = restricted !== (record.access_mode === "restricted")
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
const draftDirty = changed || Boolean(targetId.trim() || changeReason.trim());
function discardDraft() {
setRestricted(record.access_mode === "restricted");
setGrants(record.access_grants);
setTargetType("user");
setTargetId("");
setPermission("read");
setChangeReason("");
setError("");
}
function addGrant() {
const subjectId = targetId.trim();
@@ -77,8 +103,8 @@ export default function CaseShareDialog({
setTargetId("");
}
async function save() {
if (!changed || !changeReason.trim()) return;
async function save(): Promise<boolean> {
if (!changed || !changeReason.trim()) return false;
setBusy(true);
setError("");
try {
@@ -86,34 +112,70 @@ export default function CaseShareDialog({
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: crypto.randomUUID(),
idempotency_key: idempotencyKey.current,
access_mode: restricted ? "restricted" : "tenant",
access_grants: grants
});
onSaved(saved);
onClose();
setRestricted(saved.access_mode === "restricted");
setGrants(saved.access_grants);
setTargetId("");
setChangeReason("");
idempotencyKey.current = crypto.randomUUID();
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Case access could not be saved.");
return false;
} finally {
setBusy(false);
}
}
useUnsavedDraftGuard({
dirty: open && draftDirty,
title: "i18n:govoplan-cases.unsaved_access_title",
message: "i18n:govoplan-cases.unsaved_access_message",
onSave: save,
onDiscard: discardDraft
});
function close() {
if (busy) return;
if (draftDirty) requestDiscard(onClose);
else onClose();
}
async function confirmSave() {
const saved = await save();
if (!saved) return;
setConfirmOpen(false);
onClose();
}
const saveDisabledReason = busy
? CASES_I18N.saving
: !changed
? CASES_I18N.noChanges
: !changeReason.trim()
? CASES_I18N.incomplete
: undefined;
return (
<>
<Dialog
open={open}
title={`Case access - ${record.case_number}`}
onClose={onClose}
title={i18nMessage("i18n:govoplan-cases.case_access_title", { value0: record.case_number })}
onClose={close}
closeDisabled={busy}
portal
className="case-share-dialog"
footer={
<>
<Button disabled={busy} onClick={onClose}>Cancel</Button>
<Button disabled={busy} onClick={close}>Cancel</Button>
<Button
variant="primary"
disabled={busy || !changed || !changeReason.trim()}
onClick={() => void save()}
disabledReason={saveDisabledReason}
onClick={() => setConfirmOpen(true)}
>
{busy ? "Saving" : "Save access"}
</Button>
@@ -121,6 +183,7 @@ export default function CaseShareDialog({
}
>
<div className="case-share-content">
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
) : null}
@@ -139,7 +202,7 @@ export default function CaseShareDialog({
</p>
<div className="case-share-add-row">
<FormField label="Target type">
<FormField label="Target type" documentation={CASES_FIELDS_DOCUMENTATION}>
<select
value={targetType}
disabled={busy}
@@ -152,7 +215,7 @@ export default function CaseShareDialog({
<option value="group">Group</option>
</select>
</FormField>
<FormField label="Target">
<FormField label="Target" documentation={CASES_FIELDS_DOCUMENTATION}>
<ReferenceSelect
value={targetId}
onChange={setTargetId}
@@ -162,7 +225,7 @@ export default function CaseShareDialog({
aria-label={`Case access ${targetType}`}
/>
</FormField>
<FormField label="Permission">
<FormField label="Permission" documentation={CASES_FIELDS_DOCUMENTATION}>
<select
value={permission}
disabled={busy}
@@ -178,7 +241,7 @@ export default function CaseShareDialog({
label="Add access grant"
icon={<Plus size={16} />}
variant="primary"
disabled={busy || !targetId.trim()}
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : undefined}
onClick={addGrant}
/>
</div>
@@ -221,7 +284,7 @@ export default function CaseShareDialog({
))}
</div>
<FormField label="Change reason">
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
<input
value={changeReason}
disabled={busy}
@@ -232,6 +295,22 @@ export default function CaseShareDialog({
</FormField>
</div>
</Dialog>
<ConfirmDialog
open={confirmOpen}
title="i18n:govoplan-cases.access_confirm_title"
message={i18nMessage("i18n:govoplan-cases.access_confirm_message", {
value0: record.case_number,
value1: restricted
? "i18n:govoplan-cases.visibility_restricted"
: "i18n:govoplan-cases.visibility_tenant",
value2: grants.length
})}
confirmLabel="Save access"
busy={busy}
onCancel={() => setConfirmOpen(false)}
onConfirm={() => void confirmSave()}
/>
</>
);
}