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
+99 -26
View File
@@ -1,15 +1,19 @@
import { ArrowLeft, Save, Share2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router";
import {
ActionBlockerHint,
Button,
DocumentationHelpLink,
DismissibleAlert,
FormField,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
hasScope,
useGuardedNavigate,
useUnsavedDraftGuard,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
@@ -24,6 +28,11 @@ import {
type InstitutionalReference
} from "../../api/cases";
import CaseShareDialog from "./CaseShareDialog";
import {
CASES_DOCUMENTATION,
CASES_FIELDS_DOCUMENTATION,
CASES_I18N
} from "./interfacePatterns";
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
@@ -40,6 +49,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [shareOpen, setShareOpen] = 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");
@@ -84,28 +94,65 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
);
}, [canClose, catalog, record]);
const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim()));
async function save() {
if (!record || !changed || !changeReason.trim()) return;
function discardDraft() {
if (!record) return;
setTitle(record.title);
setStatus(record.status_key);
setChangeReason("");
}
async function save(): Promise<boolean> {
if (!record || !changed || !title.trim() || !changeReason.trim()) return false;
setSaving(true);
setError("");
try {
await updateCase(settings, caseId, {
const saved = await updateCase(settings, caseId, {
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: crypto.randomUUID(),
idempotency_key: idempotencyKey.current,
...(title.trim() !== record.title ? { title: title.trim() } : {}),
...(status !== record.status_key ? { status_key: status } : {})
});
await load();
setRecord(saved);
setTitle(saved.title);
setStatus(saved.status_key);
setChangeReason("");
idempotencyKey.current = crypto.randomUUID();
try {
await load();
} catch (reloadError) {
setError(reloadError instanceof Error
? `The case was saved, but its history could not be refreshed. ${reloadError.message}`
: "The case was saved, but its history could not be refreshed.");
}
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Case could not be saved.");
return false;
} finally {
setSaving(false);
}
}
useUnsavedDraftGuard({
dirty: draftDirty,
title: "i18n:govoplan-cases.unsaved_title",
message: "i18n:govoplan-cases.unsaved_message",
onSave: save,
onDiscard: discardDraft
});
const saveDisabledReason = saving
? CASES_I18N.saving
: !changed
? CASES_I18N.noChanges
: !title.trim() || !changeReason.trim()
? CASES_I18N.incomplete
: undefined;
return (
<main className="cases-page">
<div className="case-detail-shell">
@@ -115,14 +162,14 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
Cases
</button>
{record && <span>{record.case_number}</span>}
{record && canShare ? (
<IconButton
label="Manage case access"
icon={<Share2 size={16} />}
className="case-share-button"
onClick={() => setShareOpen(true)}
/>
) : null}
{record ? <IconButton
label="Manage case access"
icon={<Share2 size={16} />}
className="case-share-button"
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
onClick={() => setShareOpen(true)}
/> : null}
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
</div>
<PageScrollViewport className="case-detail-viewport">
{error &&
@@ -142,26 +189,52 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<StatusBadge status={record.closed_at ? "inactive" : "active"} label={humanize(record.status_key)} />
</div>
{!canUpdate ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "Case editing is read-only",
details: CASES_I18N.updateReason,
requiredAction: "Ask a case manager to make the required lifecycle change.",
actor: "A user with the Cases update permission",
target: "Case role or object access assignment"
}}
documentation={CASES_FIELDS_DOCUMENTATION}
/>
) : null}
{canUpdate && !canClose ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "Terminal case states are unavailable",
details: CASES_I18N.closeReason,
requiredAction: "Ask a case closer to complete the lifecycle transition.",
actor: "A user with the Cases close permission",
target: "Case role assignment"
}}
documentation={CASES_FIELDS_DOCUMENTATION}
/>
) : null}
{canUpdate &&
<div className="case-edit-panel">
<label>
<span>Title</span>
<FormField label="Title" documentation={CASES_FIELDS_DOCUMENTATION}>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
</label>
<label>
<span>Status</span>
</FormField>
<FormField label="Status" documentation={CASES_FIELDS_DOCUMENTATION}>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
{statuses.map((item) => <option key={item.status_key} value={item.status_key}>{item.label}</option>)}
</select>
</label>
<label className="case-change-reason">
<span>Change reason</span>
<input value={changeReason} onChange={(event) => setChangeReason(event.target.value)} />
</label>
</FormField>
<div className="case-change-reason">
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
<input value={changeReason} onChange={(event) => setChangeReason(event.target.value)} />
</FormField>
</div>
<Button
variant="primary"
disabled={!changed || !title.trim() || !changeReason.trim() || saving}
onClick={save}>
disabledReason={saveDisabledReason}
onClick={() => void save()}>
<Save size={16} aria-hidden="true" />
{saving ? "Saving" : "Save"}
</Button>