diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..22960ef --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -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. diff --git a/src/govoplan_cases/backend/manifest.py b/src/govoplan_cases/backend/manifest.py index 6bd2803..f9e2699 100644 --- a/src/govoplan_cases/backend/manifest.py +++ b/src/govoplan_cases/backend/manifest.py @@ -213,6 +213,14 @@ manifest = ModuleManifest( label="Case list", 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( id="cases.detail", module_id=MODULE_ID, @@ -220,6 +228,54 @@ manifest = ModuleManifest( label="Case details", 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=( @@ -313,6 +369,56 @@ manifest = ModuleManifest( 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( diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..f536f3c --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -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() diff --git a/webui/src/features/cases/CaseDetailPage.tsx b/webui/src/features/cases/CaseDetailPage.tsx index ff227e2..01bc2ce 100644 --- a/webui/src/features/cases/CaseDetailPage.tsx +++ b/webui/src/features/cases/CaseDetailPage.tsx @@ -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 { + 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 (
@@ -115,14 +162,14 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) Cases {record && {record.case_number}} - {record && canShare ? ( - } - className="case-share-button" - onClick={() => setShareOpen(true)} - /> - ) : null} + {record ? } + className="case-share-button" + disabledReason={!canShare ? CASES_I18N.shareReason : undefined} + onClick={() => setShareOpen(true)} + /> : null} +
{error && @@ -142,26 +189,52 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) + {!canUpdate ? ( + + ) : null} + {canUpdate && !canClose ? ( + + ) : null} + {canUpdate &&
- - - + +
+ + setChangeReason(event.target.value)} /> + +
diff --git a/webui/src/features/cases/CaseShareDialog.tsx b/webui/src/features/cases/CaseShareDialog.tsx index 412ae78..4cdad0b 100644 --- a/webui/src/features/cases/CaseShareDialog.tsx +++ b/webui/src/features/cases/CaseShareDialog.tsx @@ -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 { + 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 ( + <> - + @@ -121,6 +183,7 @@ export default function CaseShareDialog({ } >
+ {error ? ( {error} ) : null} @@ -139,7 +202,7 @@ export default function CaseShareDialog({

- + - + - +
+ setConfirmOpen(false)} + onConfirm={() => void confirmSave()} + /> + ); } diff --git a/webui/src/features/cases/CasesPage.tsx b/webui/src/features/cases/CasesPage.tsx index 3847f45..fbd3e10 100644 --- a/webui/src/features/cases/CasesPage.tsx +++ b/webui/src/features/cases/CasesPage.tsx @@ -1,10 +1,13 @@ import { Search } from "lucide-react"; import { useEffect, useMemo, useState, type FormEvent } from "react"; import { + Button, + DocumentationHelpLink, DismissibleAlert, LoadingIndicator, PageScrollViewport, StatusBadge, + i18nMessage, useGuardedNavigate, type PlatformRouteContext } from "@govoplan/core-webui"; @@ -14,6 +17,7 @@ import { type CaseCatalog, type CaseRecord } from "../../api/cases"; +import { CASES_DOCUMENTATION, CASES_I18N } from "./interfacePatterns"; export default function CasesPage({ settings }: PlatformRouteContext) { @@ -79,7 +83,13 @@ export default function CasesPage({ settings }: PlatformRouteContext) { aria-label="Search cases" placeholder="Search cases" /> - + - {total} cases + {i18nMessage("i18n:govoplan-cases.case_count", { value0: total })} +
{error && diff --git a/webui/src/features/cases/interfacePatterns.ts b/webui/src/features/cases/interfacePatterns.ts new file mode 100644 index 0000000..85039f6 --- /dev/null +++ b/webui/src/features/cases/interfacePatterns.ts @@ -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; diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts new file mode 100644 index 0000000..1f063b4 --- /dev/null +++ b/webui/src/i18n/generatedTranslations.ts @@ -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 = { + "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 }; diff --git a/webui/src/module.ts b/webui/src/module.ts index 9ebc26b..50bea6d 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -1,5 +1,6 @@ import { createElement, lazy } from "react"; import type { PlatformWebModule } from "@govoplan/core-webui"; +import { generatedTranslations } from "./i18n/generatedTranslations"; import "./styles/cases.css"; @@ -8,7 +9,7 @@ const CaseDetailPage = lazy(() => import("./features/cases/CaseDetailPage")); export const casesModule: PlatformWebModule = { id: "cases", - label: "Cases", + label: "i18n:govoplan-cases.cases", version: "0.1.8", optionalDependencies: [ "access", @@ -20,6 +21,7 @@ export const casesModule: PlatformWebModule = { "forms_runtime", "workflow_engine" ], + translations: generatedTranslations, routes: [ { path: "/cases", @@ -39,7 +41,7 @@ export const casesModule: PlatformWebModule = { navItems: [ { to: "/cases", - label: "Cases", + label: "i18n:govoplan-cases.cases", iconName: "briefcase-business", anyOf: ["cases:case:read"], order: 35, @@ -47,9 +49,16 @@ export const casesModule: PlatformWebModule = { } ], viewSurfaces: [ - { id: "cases.navigation", moduleId: "cases", kind: "navigation", label: "Cases navigation", order: 10 }, - { id: "cases.list", moduleId: "cases", kind: "route", label: "Case list", order: 20 }, - { id: "cases.detail", moduleId: "cases", kind: "route", label: "Case details", order: 30 } + { id: "cases.navigation", moduleId: "cases", kind: "navigation", label: "i18n:govoplan-cases.navigation", order: 10 }, + { id: "cases.list", moduleId: "cases", kind: "route", label: "i18n:govoplan-cases.list", order: 20 }, + { 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 } ] }; diff --git a/webui/src/styles/cases.css b/webui/src/styles/cases.css index b8c16cf..13481a0 100644 --- a/webui/src/styles/cases.css +++ b/webui/src/styles/cases.css @@ -143,6 +143,10 @@ letter-spacing: 0; } +.case-detail-main > .action-blocker-hint { + margin-top: 16px; +} + .case-detail-eyebrow { color: var(--text-soft); font-size: 0.8rem;