Migrate Cases interface patterns
This commit is contained in:
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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 }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user