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
+39
View File
@@ -0,0 +1,39 @@
# Cases Interface Pattern Migration
This migration applies the GovOPlaN interface pattern language to the case
directory, governed case detail, lifecycle editor, institutional references,
immutable evidence, and object-level access dialog.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `/cases` | Governed directory | Search and select a readable case | Stable loading, empty, error, filter, count, and contextual-help states |
| Case summary | Governed object detail | Inspect current revision and lifecycle state | Privacy-safe title, type, status, dates, revision, and reason rendering |
| Lifecycle editor | Consequential record editor | Append revision or close case | Guarded draft, OCC revision, stable idempotency key, explicit reason, permission explanation, and save/discard |
| Institutional references | Provider-owned reference list | Inspect linked objects | Stable owner/object/version references without copying sibling-module state |
| Timeline and history | Immutable evidence view | Inspect recorded lifecycle evidence | Actor-safe summaries, revision order, timestamps, and no historical mutation controls |
| Access dialog | Governed object-access editor | Change visibility or explicit grants | Searchable account/group references, guarded nested draft, confirmation, OCC revision, reason, and permission boundary |
## Consequence And Availability Rules
- Every accepted title, status, visibility, or grant change appends an immutable
revision and timeline event. Existing history is never rewritten.
- Updates require `cases:case:update`; terminal statuses additionally require
`cases:case:close`; access changes require `cases:case:share`.
- A missing permission leaves the readable case available and explains the
actor, required action, and administrative destination instead of hiding the
entire object.
- Restricted visibility is evaluated by the Cases ACL provider. The WebUI
selector discovers accounts and groups through shared reference providers;
it does not import Access internals.
- Main and access-dialog drafts are guarded. A direct access save requires a
separate confirmation that identifies the case, visibility, and grant count.
- Service, party, assignment, Decision, and record links remain references to
provider-owned objects. Optional modules may enrich those objects without
becoming runtime dependencies of Cases.
Backend and WebUI manifests publish the same navigation, route, section, and
action surface identifiers. English and German catalogues cover module-owned
navigation, blocker, guard, and confirmation vocabulary. Contextual help links
resolve to the module-owned manifest documentation.
+106
View File
@@ -213,6 +213,14 @@ manifest = ModuleManifest(
label="Case list", label="Case list",
order=20, order=20,
), ),
ViewSurface(
id="cases.list.filters",
module_id=MODULE_ID,
kind="section",
label="Case search and filters",
parent_id="cases.list",
order=10,
),
ViewSurface( ViewSurface(
id="cases.detail", id="cases.detail",
module_id=MODULE_ID, module_id=MODULE_ID,
@@ -220,6 +228,54 @@ manifest = ModuleManifest(
label="Case details", label="Case details",
order=30, order=30,
), ),
ViewSurface(
id="cases.detail.summary",
module_id=MODULE_ID,
kind="section",
label="Case summary",
parent_id="cases.detail",
order=10,
),
ViewSurface(
id="cases.detail.editor",
module_id=MODULE_ID,
kind="section",
label="Case lifecycle editor",
parent_id="cases.detail",
order=20,
),
ViewSurface(
id="cases.detail.references",
module_id=MODULE_ID,
kind="section",
label="Institutional references",
parent_id="cases.detail",
order=30,
),
ViewSurface(
id="cases.detail.timeline",
module_id=MODULE_ID,
kind="section",
label="Case timeline",
parent_id="cases.detail",
order=40,
),
ViewSurface(
id="cases.detail.history",
module_id=MODULE_ID,
kind="section",
label="Immutable case history",
parent_id="cases.detail",
order=50,
),
ViewSurface(
id="cases.detail.access",
module_id=MODULE_ID,
kind="action",
label="Case access",
parent_id="cases.detail",
order=60,
),
), ),
), ),
provides_interfaces=( provides_interfaces=(
@@ -313,6 +369,56 @@ manifest = ModuleManifest(
kind="repository", kind="repository",
), ),
), ),
metadata={
"help_contexts": [
"cases.list",
"cases.detail",
"cases.state.read-only",
"cases.state.restricted",
],
},
),
DocumentationTopic(
id="cases.reference.lifecycle-access-and-evidence",
title="Case lifecycle, access, and evidence reference",
summary="Explains revision, status, access, and reference fields together with their durable consequences.",
body=(
"Title and status changes append an immutable case revision guarded by the "
"expected revision and a stable idempotency key. Every accepted change also "
"appends a timeline entry with actor, time, and change reason; a terminal "
"status additionally requires the case-close permission. Case visibility is "
"tenant-wide or restricted. Restricted cases remain visible only through "
"administrative authority, assignment or unit context, creator authority, or "
"an explicit account/group grant. Access changes append another immutable "
"revision and require confirmation. Service, party, assignment, Decision, and "
"record references identify provider-owned objects; Cases preserves their "
"stable identifiers and versions without copying or silently changing them."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin"),
links=(
DocumentationLink(
label="Cases interface pattern migration",
href="govoplan-cases/docs/INTERFACE_PATTERN_MIGRATION.md",
kind="repository",
),
),
metadata={
"help_contexts": [
"cases.field.title",
"cases.field.status",
"cases.field.change-reason",
"cases.field.visibility",
"cases.field.access-grant",
"cases.state.close-unavailable",
],
"consequence_classes": {
"update_case": "append an OCC-guarded immutable revision and timeline event",
"close_case": "append a terminal revision after close-scope authorization",
"change_access": "append a confirmed visibility/grant revision and timeline event",
},
},
), ),
), ),
architecture=ModuleArchitectureDeclaration( architecture=ModuleArchitectureDeclaration(
@@ -0,0 +1,83 @@
from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_cases.backend.manifest import get_manifest
REPO_ROOT = Path(__file__).resolve().parents[1]
class CasesInterfaceDocumentationContractTests(unittest.TestCase):
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
frontend = get_manifest().frontend
self.assertIsNotNone(frontend)
surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr]
self.assertEqual(
{
"cases.navigation",
"cases.list",
"cases.list.filters",
"cases.detail",
"cases.detail.summary",
"cases.detail.editor",
"cases.detail.references",
"cases.detail.timeline",
"cases.detail.history",
"cases.detail.access",
},
set(surfaces),
)
self.assertEqual("cases.list", surfaces["cases.list.filters"].parent_id)
for surface_id in (
"cases.detail.summary",
"cases.detail.editor",
"cases.detail.references",
"cases.detail.timeline",
"cases.detail.history",
"cases.detail.access",
):
self.assertEqual("cases.detail", surfaces[surface_id].parent_id)
def test_help_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in get_manifest().documentation}
context = topics["cases.institutional-context"]
reference = topics["cases.reference.lifecycle-access-and-evidence"]
self.assertIn("cases.state.read-only", context.metadata["help_contexts"])
self.assertIn("cases.field.access-grant", reference.metadata["help_contexts"])
self.assertIn("update_case", reference.metadata["consequence_classes"])
self.assertIn("close_case", reference.metadata["consequence_classes"])
self.assertIn("change_access", reference.metadata["consequence_classes"])
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
list_page = (
REPO_ROOT / "webui/src/features/cases/CasesPage.tsx"
).read_text(encoding="utf-8")
detail_page = (
REPO_ROOT / "webui/src/features/cases/CaseDetailPage.tsx"
).read_text(encoding="utf-8")
access_dialog = (
REPO_ROOT / "webui/src/features/cases/CaseShareDialog.tsx"
).read_text(encoding="utf-8")
self.assertIn("DocumentationHelpLink", list_page)
for component in (
"ActionBlockerHint",
"DocumentationHelpLink",
"FormField",
"useUnsavedDraftGuard",
):
self.assertIn(component, detail_page)
for component in (
"ConfirmDialog",
"DocumentationHelpLink",
"ReferenceSelect",
"useUnsavedDraftGuard",
):
self.assertIn(component, access_dialog)
if __name__ == "__main__":
unittest.main()
+99 -26
View File
@@ -1,15 +1,19 @@
import { ArrowLeft, Save, Share2 } from "lucide-react"; 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 { useParams } from "react-router";
import { import {
ActionBlockerHint,
Button, Button,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FormField,
IconButton, IconButton,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
StatusBadge, StatusBadge,
hasScope, hasScope,
useGuardedNavigate, useGuardedNavigate,
useUnsavedDraftGuard,
type PlatformRouteContext type PlatformRouteContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -24,6 +28,11 @@ import {
type InstitutionalReference type InstitutionalReference
} from "../../api/cases"; } from "../../api/cases";
import CaseShareDialog from "./CaseShareDialog"; import CaseShareDialog from "./CaseShareDialog";
import {
CASES_DOCUMENTATION,
CASES_FIELDS_DOCUMENTATION,
CASES_I18N
} from "./interfacePatterns";
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) { export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
@@ -40,6 +49,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [shareOpen, setShareOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false);
const idempotencyKey = useRef(crypto.randomUUID());
const canUpdate = hasScope(auth, "cases:case:update"); const canUpdate = hasScope(auth, "cases:case:update");
const canClose = hasScope(auth, "cases:case:close"); const canClose = hasScope(auth, "cases:case:close");
const canShare = hasScope(auth, "cases:case:share"); const canShare = hasScope(auth, "cases:case:share");
@@ -84,28 +94,65 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
); );
}, [canClose, catalog, record]); }, [canClose, catalog, record]);
const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key)); const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim()));
async function save() { function discardDraft() {
if (!record || !changed || !changeReason.trim()) return; 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); setSaving(true);
setError(""); setError("");
try { try {
await updateCase(settings, caseId, { const saved = await updateCase(settings, caseId, {
expected_revision: record.revision, expected_revision: record.revision,
recorded_at: new Date().toISOString(), recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(), change_reason: changeReason.trim(),
idempotency_key: crypto.randomUUID(), idempotency_key: idempotencyKey.current,
...(title.trim() !== record.title ? { title: title.trim() } : {}), ...(title.trim() !== record.title ? { title: title.trim() } : {}),
...(status !== record.status_key ? { status_key: status } : {}) ...(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) { } catch (reason) {
setError(reason instanceof Error ? reason.message : "Case could not be saved."); setError(reason instanceof Error ? reason.message : "Case could not be saved.");
return false;
} finally { } finally {
setSaving(false); 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 ( return (
<main className="cases-page"> <main className="cases-page">
<div className="case-detail-shell"> <div className="case-detail-shell">
@@ -115,14 +162,14 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
Cases Cases
</button> </button>
{record && <span>{record.case_number}</span>} {record && <span>{record.case_number}</span>}
{record && canShare ? ( {record ? <IconButton
<IconButton label="Manage case access"
label="Manage case access" icon={<Share2 size={16} />}
icon={<Share2 size={16} />} className="case-share-button"
className="case-share-button" disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
onClick={() => setShareOpen(true)} onClick={() => setShareOpen(true)}
/> /> : null}
) : null} <DocumentationHelpLink reference={CASES_DOCUMENTATION} />
</div> </div>
<PageScrollViewport className="case-detail-viewport"> <PageScrollViewport className="case-detail-viewport">
{error && {error &&
@@ -142,26 +189,52 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<StatusBadge status={record.closed_at ? "inactive" : "active"} label={humanize(record.status_key)} /> <StatusBadge status={record.closed_at ? "inactive" : "active"} label={humanize(record.status_key)} />
</div> </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 && {canUpdate &&
<div className="case-edit-panel"> <div className="case-edit-panel">
<label> <FormField label="Title" documentation={CASES_FIELDS_DOCUMENTATION}>
<span>Title</span>
<input value={title} onChange={(event) => setTitle(event.target.value)} /> <input value={title} onChange={(event) => setTitle(event.target.value)} />
</label> </FormField>
<label> <FormField label="Status" documentation={CASES_FIELDS_DOCUMENTATION}>
<span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}> <select value={status} onChange={(event) => setStatus(event.target.value)}>
{statuses.map((item) => <option key={item.status_key} value={item.status_key}>{item.label}</option>)} {statuses.map((item) => <option key={item.status_key} value={item.status_key}>{item.label}</option>)}
</select> </select>
</label> </FormField>
<label className="case-change-reason"> <div className="case-change-reason">
<span>Change reason</span> <FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
<input value={changeReason} onChange={(event) => setChangeReason(event.target.value)} /> <input value={changeReason} onChange={(event) => setChangeReason(event.target.value)} />
</label> </FormField>
</div>
<Button <Button
variant="primary" variant="primary"
disabled={!changed || !title.trim() || !changeReason.trim() || saving} disabledReason={saveDisabledReason}
onClick={save}> onClick={() => void save()}>
<Save size={16} aria-hidden="true" /> <Save size={16} aria-hidden="true" />
{saving ? "Saving" : "Save"} {saving ? "Saving" : "Save"}
</Button> </Button>
+94 -15
View File
@@ -1,13 +1,18 @@
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
Button, Button,
ConfirmDialog,
Dialog, Dialog,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FormField, FormField,
IconButton, IconButton,
ReferenceSelect, ReferenceSelect,
ToggleSwitch, ToggleSwitch,
i18nMessage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings type ApiSettings
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -16,6 +21,10 @@ import {
type CaseGrant, type CaseGrant,
type CaseRecord type CaseRecord
} from "../../api/cases"; } from "../../api/cases";
import {
CASES_FIELDS_DOCUMENTATION,
CASES_I18N
} from "./interfacePatterns";
type TargetType = "user" | "group"; type TargetType = "user" | "group";
@@ -42,6 +51,9 @@ export default function CaseShareDialog({
const [changeReason, setChangeReason] = useState(""); const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [confirmOpen, setConfirmOpen] = useState(false);
const idempotencyKey = useRef(crypto.randomUUID());
const { requestDiscard } = useUnsavedChanges();
const targetProvider = useMemo( const targetProvider = useMemo(
() => caseShareTargetProvider(settings, record.reference.object_id, targetType), () => caseShareTargetProvider(settings, record.reference.object_id, targetType),
[record.reference.object_id, settings, targetType] [record.reference.object_id, settings, targetType]
@@ -51,14 +63,28 @@ export default function CaseShareDialog({
if (!open) return; if (!open) return;
setRestricted(record.access_mode === "restricted"); setRestricted(record.access_mode === "restricted");
setGrants(record.access_grants); setGrants(record.access_grants);
setTargetType("user");
setTargetId(""); setTargetId("");
setPermission("read"); setPermission("read");
setChangeReason(""); setChangeReason("");
setError(""); setError("");
setConfirmOpen(false);
idempotencyKey.current = crypto.randomUUID();
}, [open, record]); }, [open, record]);
const changed = restricted !== (record.access_mode === "restricted") const changed = restricted !== (record.access_mode === "restricted")
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants); || 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() { function addGrant() {
const subjectId = targetId.trim(); const subjectId = targetId.trim();
@@ -77,8 +103,8 @@ export default function CaseShareDialog({
setTargetId(""); setTargetId("");
} }
async function save() { async function save(): Promise<boolean> {
if (!changed || !changeReason.trim()) return; if (!changed || !changeReason.trim()) return false;
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
@@ -86,34 +112,70 @@ export default function CaseShareDialog({
expected_revision: record.revision, expected_revision: record.revision,
recorded_at: new Date().toISOString(), recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(), change_reason: changeReason.trim(),
idempotency_key: crypto.randomUUID(), idempotency_key: idempotencyKey.current,
access_mode: restricted ? "restricted" : "tenant", access_mode: restricted ? "restricted" : "tenant",
access_grants: grants access_grants: grants
}); });
onSaved(saved); onSaved(saved);
onClose(); setRestricted(saved.access_mode === "restricted");
setGrants(saved.access_grants);
setTargetId("");
setChangeReason("");
idempotencyKey.current = crypto.randomUUID();
return true;
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : "Case access could not be saved."); setError(reason instanceof Error ? reason.message : "Case access could not be saved.");
return false;
} finally { } finally {
setBusy(false); 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 ( return (
<>
<Dialog <Dialog
open={open} open={open}
title={`Case access - ${record.case_number}`} title={i18nMessage("i18n:govoplan-cases.case_access_title", { value0: record.case_number })}
onClose={onClose} onClose={close}
closeDisabled={busy} closeDisabled={busy}
portal portal
className="case-share-dialog" className="case-share-dialog"
footer={ footer={
<> <>
<Button disabled={busy} onClick={onClose}>Cancel</Button> <Button disabled={busy} onClick={close}>Cancel</Button>
<Button <Button
variant="primary" variant="primary"
disabled={busy || !changed || !changeReason.trim()} disabledReason={saveDisabledReason}
onClick={() => void save()} onClick={() => setConfirmOpen(true)}
> >
{busy ? "Saving" : "Save access"} {busy ? "Saving" : "Save access"}
</Button> </Button>
@@ -121,6 +183,7 @@ export default function CaseShareDialog({
} }
> >
<div className="case-share-content"> <div className="case-share-content">
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
{error ? ( {error ? (
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
) : null} ) : null}
@@ -139,7 +202,7 @@ export default function CaseShareDialog({
</p> </p>
<div className="case-share-add-row"> <div className="case-share-add-row">
<FormField label="Target type"> <FormField label="Target type" documentation={CASES_FIELDS_DOCUMENTATION}>
<select <select
value={targetType} value={targetType}
disabled={busy} disabled={busy}
@@ -152,7 +215,7 @@ export default function CaseShareDialog({
<option value="group">Group</option> <option value="group">Group</option>
</select> </select>
</FormField> </FormField>
<FormField label="Target"> <FormField label="Target" documentation={CASES_FIELDS_DOCUMENTATION}>
<ReferenceSelect <ReferenceSelect
value={targetId} value={targetId}
onChange={setTargetId} onChange={setTargetId}
@@ -162,7 +225,7 @@ export default function CaseShareDialog({
aria-label={`Case access ${targetType}`} aria-label={`Case access ${targetType}`}
/> />
</FormField> </FormField>
<FormField label="Permission"> <FormField label="Permission" documentation={CASES_FIELDS_DOCUMENTATION}>
<select <select
value={permission} value={permission}
disabled={busy} disabled={busy}
@@ -178,7 +241,7 @@ export default function CaseShareDialog({
label="Add access grant" label="Add access grant"
icon={<Plus size={16} />} icon={<Plus size={16} />}
variant="primary" variant="primary"
disabled={busy || !targetId.trim()} disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : undefined}
onClick={addGrant} onClick={addGrant}
/> />
</div> </div>
@@ -221,7 +284,7 @@ export default function CaseShareDialog({
))} ))}
</div> </div>
<FormField label="Change reason"> <FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
<input <input
value={changeReason} value={changeReason}
disabled={busy} disabled={busy}
@@ -232,6 +295,22 @@ export default function CaseShareDialog({
</FormField> </FormField>
</div> </div>
</Dialog> </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()}
/>
</>
); );
} }
+13 -2
View File
@@ -1,10 +1,13 @@
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react"; import { useEffect, useMemo, useState, type FormEvent } from "react";
import { import {
Button,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
StatusBadge, StatusBadge,
i18nMessage,
useGuardedNavigate, useGuardedNavigate,
type PlatformRouteContext type PlatformRouteContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
@@ -14,6 +17,7 @@ import {
type CaseCatalog, type CaseCatalog,
type CaseRecord type CaseRecord
} from "../../api/cases"; } from "../../api/cases";
import { CASES_DOCUMENTATION, CASES_I18N } from "./interfacePatterns";
export default function CasesPage({ settings }: PlatformRouteContext) { export default function CasesPage({ settings }: PlatformRouteContext) {
@@ -79,7 +83,13 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
aria-label="Search cases" aria-label="Search cases"
placeholder="Search cases" placeholder="Search cases"
/> />
<button type="submit" className="btn btn-primary">Search</button> <Button
type="submit"
variant="primary"
disabledReason={loading ? CASES_I18N.loading : undefined}
>
Search
</Button>
</form> </form>
<label className="cases-status-filter"> <label className="cases-status-filter">
<span>Status</span> <span>Status</span>
@@ -90,7 +100,8 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
)} )}
</select> </select>
</label> </label>
<span className="cases-count">{total} cases</span> <span className="cases-count">{i18nMessage("i18n:govoplan-cases.case_count", { value0: total })}</span>
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
</div> </div>
<PageScrollViewport className="cases-list-viewport"> <PageScrollViewport className="cases-list-viewport">
{error && {error &&
@@ -0,0 +1,22 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const CASES_DOCUMENTATION = {
topicId: "cases.institutional-context",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const CASES_FIELDS_DOCUMENTATION = {
topicId: "cases.reference.lifecycle-access-and-evidence",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const CASES_I18N = {
loading: "i18n:govoplan-cases.loading_reason",
saving: "i18n:govoplan-cases.saving_reason",
updateReason: "i18n:govoplan-cases.update_reason",
closeReason: "i18n:govoplan-cases.close_reason",
shareReason: "i18n:govoplan-cases.share_reason",
noChanges: "i18n:govoplan-cases.no_changes_reason",
incomplete: "i18n:govoplan-cases.incomplete_reason",
targetRequired: "i18n:govoplan-cases.target_required_reason"
} as const;
+165
View File
@@ -0,0 +1,165 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-cases.cases": "Cases",
"i18n:govoplan-cases.navigation": "Cases navigation",
"i18n:govoplan-cases.list": "Case list",
"i18n:govoplan-cases.filters": "Case search and filters",
"i18n:govoplan-cases.detail": "Case details",
"i18n:govoplan-cases.summary": "Case summary",
"i18n:govoplan-cases.editor": "Case lifecycle editor",
"i18n:govoplan-cases.references": "Institutional references",
"i18n:govoplan-cases.timeline": "Case timeline",
"i18n:govoplan-cases.history": "Immutable case history",
"i18n:govoplan-cases.access": "Case access",
"i18n:govoplan-cases.loading_reason": "The case is still loading.",
"i18n:govoplan-cases.saving_reason": "The case change is still being saved.",
"i18n:govoplan-cases.update_reason": "Your account may inspect this case but may not change its title or lifecycle state.",
"i18n:govoplan-cases.close_reason": "Closing a case requires the case-close permission.",
"i18n:govoplan-cases.share_reason": "Changing case visibility or grants requires the case-share permission.",
"i18n:govoplan-cases.no_changes_reason": "There are no case changes to save.",
"i18n:govoplan-cases.incomplete_reason": "Enter a title and a reason that explains the recorded change.",
"i18n:govoplan-cases.target_required_reason": "Select a user or group before adding an access grant.",
"i18n:govoplan-cases.unsaved_title": "Unsaved case change",
"i18n:govoplan-cases.unsaved_message": "Save or discard the case title, status, and reason before leaving.",
"i18n:govoplan-cases.unsaved_access_title": "Unsaved case access change",
"i18n:govoplan-cases.unsaved_access_message": "Save or discard the visibility and grant changes before leaving this dialog.",
"i18n:govoplan-cases.access_confirm_title": "Change case access",
"i18n:govoplan-cases.access_confirm_message": "Change {value0} to {value1} visibility with {value2} explicit grant(s)? This appends an immutable case revision and timeline entry.",
"i18n:govoplan-cases.case_access_title": "Case access - {value0}",
"i18n:govoplan-cases.case_count": "{value0} cases",
"i18n:govoplan-cases.visibility_tenant": "tenant",
"i18n:govoplan-cases.visibility_restricted": "restricted",
"Case editing is read-only": "Case editing is read-only",
"Ask a case manager to make the required lifecycle change.": "Ask a case manager to make the required lifecycle change.",
"A user with the Cases update permission": "A user with the Cases update permission",
"Case role or object access assignment": "Case role or object access assignment",
"Terminal case states are unavailable": "Terminal case states are unavailable",
"Ask a case closer to complete the lifecycle transition.": "Ask a case closer to complete the lifecycle transition.",
"A user with the Cases close permission": "A user with the Cases close permission",
"Case role assignment": "Case role assignment",
"Cases": "Cases",
"Loading cases": "Loading cases",
"Loading case": "Loading case",
"User": "User",
"Group": "Group",
"Read": "Read",
"Update": "Update",
"Share": "Share",
"Administer": "Administer",
"Cancel": "Cancel",
"Add access grant": "Add access grant",
"Explicit access grants": "Explicit access grants",
"Why is case access changing?": "Why is case access changing?",
"Search cases": "Search cases",
"Search": "Search",
"Status": "Status",
"All statuses": "All statuses",
"No matching cases.": "No matching cases.",
"Manage case access": "Manage case access",
"Title": "Title",
"Change reason": "Change reason",
"Save": "Save",
"Saving": "Saving",
"Opened": "Opened",
"Deadline": "Deadline",
"Revision": "Revision",
"Last change": "Last change",
"Parties": "Parties",
"Assignments": "Assignments",
"Decisions": "Decisions",
"Records": "Records",
"Timeline": "Timeline",
"History": "History",
"Case visibility": "Case visibility",
"Tenant": "Tenant",
"Restricted": "Restricted",
"Target type": "Target type",
"Target": "Target",
"Permission": "Permission",
"Save access": "Save access",
"No explicit access grants.": "No explicit access grants."
} as const;
const de: Record<keyof typeof en, string> = {
"i18n:govoplan-cases.cases": "Vorgänge",
"i18n:govoplan-cases.navigation": "Vorgangsnavigation",
"i18n:govoplan-cases.list": "Vorgangsliste",
"i18n:govoplan-cases.filters": "Vorgangssuche und Filter",
"i18n:govoplan-cases.detail": "Vorgangsdetails",
"i18n:govoplan-cases.summary": "Vorgangszusammenfassung",
"i18n:govoplan-cases.editor": "Vorgangsstatus bearbeiten",
"i18n:govoplan-cases.references": "Institutionelle Referenzen",
"i18n:govoplan-cases.timeline": "Vorgangszeitachse",
"i18n:govoplan-cases.history": "Unveränderliche Vorgangshistorie",
"i18n:govoplan-cases.access": "Vorgangszugriff",
"i18n:govoplan-cases.loading_reason": "Der Vorgang wird noch geladen.",
"i18n:govoplan-cases.saving_reason": "Die Vorgangsänderung wird noch gespeichert.",
"i18n:govoplan-cases.update_reason": "Ihr Konto darf diesen Vorgang einsehen, aber Titel und Status nicht ändern.",
"i18n:govoplan-cases.close_reason": "Zum Schließen eines Vorgangs ist die Berechtigung zum Vorgangsabschluss erforderlich.",
"i18n:govoplan-cases.share_reason": "Zum Ändern von Sichtbarkeit oder Freigaben ist die Freigabeberechtigung erforderlich.",
"i18n:govoplan-cases.no_changes_reason": "Es gibt keine Vorgangsänderungen zu speichern.",
"i18n:govoplan-cases.incomplete_reason": "Geben Sie einen Titel und eine Begründung für die protokollierte Änderung ein.",
"i18n:govoplan-cases.target_required_reason": "Wählen Sie eine Person oder Gruppe aus, bevor Sie eine Zugriffsfreigabe hinzufügen.",
"i18n:govoplan-cases.unsaved_title": "Ungespeicherte Vorgangsänderung",
"i18n:govoplan-cases.unsaved_message": "Speichern oder verwerfen Sie Titel, Status und Begründung, bevor Sie fortfahren.",
"i18n:govoplan-cases.unsaved_access_title": "Ungespeicherte Zugriffsänderung",
"i18n:govoplan-cases.unsaved_access_message": "Speichern oder verwerfen Sie Sichtbarkeit und Freigaben, bevor Sie den Dialog verlassen.",
"i18n:govoplan-cases.access_confirm_title": "Vorgangszugriff ändern",
"i18n:govoplan-cases.access_confirm_message": "Sichtbarkeit von {value0} auf {value1} mit {value2} ausdrücklichen Freigabe(n) ändern? Dadurch werden eine unveränderliche Vorgangsrevision und ein Zeitachseneintrag angelegt.",
"i18n:govoplan-cases.case_access_title": "Vorgangszugriff - {value0}",
"i18n:govoplan-cases.case_count": "{value0} Vorgänge",
"i18n:govoplan-cases.visibility_tenant": "mandantenweit",
"i18n:govoplan-cases.visibility_restricted": "eingeschränkt",
"Case editing is read-only": "Der Vorgang kann nur gelesen werden",
"Ask a case manager to make the required lifecycle change.": "Bitten Sie eine Vorgangsverwaltung, die erforderliche Statusänderung vorzunehmen.",
"A user with the Cases update permission": "Eine Person mit der Berechtigung zur Vorgangsänderung",
"Case role or object access assignment": "Vorgangsrolle oder Objektfreigabe",
"Terminal case states are unavailable": "Abschließende Vorgangsstatus sind nicht verfügbar",
"Ask a case closer to complete the lifecycle transition.": "Bitten Sie eine berechtigte Person, den Vorgangsabschluss vorzunehmen.",
"A user with the Cases close permission": "Eine Person mit der Berechtigung zum Vorgangsabschluss",
"Case role assignment": "Vorgangsrollenzuweisung",
"Cases": "Vorgänge",
"Loading cases": "Vorgänge werden geladen",
"Loading case": "Vorgang wird geladen",
"User": "Person",
"Group": "Gruppe",
"Read": "Lesen",
"Update": "Ändern",
"Share": "Freigeben",
"Administer": "Verwalten",
"Cancel": "Abbrechen",
"Add access grant": "Zugriffsfreigabe hinzufügen",
"Explicit access grants": "Ausdrückliche Zugriffsfreigaben",
"Why is case access changing?": "Warum wird der Vorgangszugriff geändert?",
"Search cases": "Vorgänge suchen",
"Search": "Suchen",
"Status": "Status",
"All statuses": "Alle Status",
"No matching cases.": "Keine passenden Vorgänge.",
"Manage case access": "Vorgangszugriff verwalten",
"Title": "Titel",
"Change reason": "Änderungsbegründung",
"Save": "Speichern",
"Saving": "Speichert",
"Opened": "Eröffnet",
"Deadline": "Frist",
"Revision": "Revision",
"Last change": "Letzte Änderung",
"Parties": "Beteiligte",
"Assignments": "Zuweisungen",
"Decisions": "Entscheidungen",
"Records": "Akten",
"Timeline": "Zeitachse",
"History": "Historie",
"Case visibility": "Vorgangssichtbarkeit",
"Tenant": "Mandant",
"Restricted": "Eingeschränkt",
"Target type": "Zieltyp",
"Target": "Ziel",
"Permission": "Berechtigung",
"Save access": "Zugriff speichern",
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben."
};
export const generatedTranslations: PlatformTranslations = { en, de };
+14 -5
View File
@@ -1,5 +1,6 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui"; import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/cases.css"; import "./styles/cases.css";
@@ -8,7 +9,7 @@ const CaseDetailPage = lazy(() => import("./features/cases/CaseDetailPage"));
export const casesModule: PlatformWebModule = { export const casesModule: PlatformWebModule = {
id: "cases", id: "cases",
label: "Cases", label: "i18n:govoplan-cases.cases",
version: "0.1.8", version: "0.1.8",
optionalDependencies: [ optionalDependencies: [
"access", "access",
@@ -20,6 +21,7 @@ export const casesModule: PlatformWebModule = {
"forms_runtime", "forms_runtime",
"workflow_engine" "workflow_engine"
], ],
translations: generatedTranslations,
routes: [ routes: [
{ {
path: "/cases", path: "/cases",
@@ -39,7 +41,7 @@ export const casesModule: PlatformWebModule = {
navItems: [ navItems: [
{ {
to: "/cases", to: "/cases",
label: "Cases", label: "i18n:govoplan-cases.cases",
iconName: "briefcase-business", iconName: "briefcase-business",
anyOf: ["cases:case:read"], anyOf: ["cases:case:read"],
order: 35, order: 35,
@@ -47,9 +49,16 @@ export const casesModule: PlatformWebModule = {
} }
], ],
viewSurfaces: [ viewSurfaces: [
{ id: "cases.navigation", moduleId: "cases", kind: "navigation", label: "Cases navigation", order: 10 }, { id: "cases.navigation", moduleId: "cases", kind: "navigation", label: "i18n:govoplan-cases.navigation", order: 10 },
{ id: "cases.list", moduleId: "cases", kind: "route", label: "Case list", order: 20 }, { id: "cases.list", moduleId: "cases", kind: "route", label: "i18n:govoplan-cases.list", order: 20 },
{ id: "cases.detail", moduleId: "cases", kind: "route", label: "Case details", order: 30 } { id: "cases.list.filters", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.filters", parentId: "cases.list", order: 10 },
{ id: "cases.detail", moduleId: "cases", kind: "route", label: "i18n:govoplan-cases.detail", order: 30 },
{ id: "cases.detail.summary", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.summary", parentId: "cases.detail", order: 10 },
{ id: "cases.detail.editor", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.editor", parentId: "cases.detail", order: 20 },
{ id: "cases.detail.references", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.references", parentId: "cases.detail", order: 30 },
{ id: "cases.detail.timeline", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.timeline", parentId: "cases.detail", order: 40 },
{ id: "cases.detail.history", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.history", parentId: "cases.detail", order: 50 },
{ id: "cases.detail.access", moduleId: "cases", kind: "action", label: "i18n:govoplan-cases.access", parentId: "cases.detail", order: 60 }
] ]
}; };
+4
View File
@@ -143,6 +143,10 @@
letter-spacing: 0; letter-spacing: 0;
} }
.case-detail-main > .action-blocker-hint {
margin-top: 16px;
}
.case-detail-eyebrow { .case-detail-eyebrow {
color: var(--text-soft); color: var(--text-soft);
font-size: 0.8rem; font-size: 0.8rem;