Migrate Notifications interface patterns

This commit is contained in:
2026-08-03 15:33:49 +02:00
parent 3b1a87b3e2
commit ad6a31f68b
13 changed files with 825 additions and 104 deletions
+27
View File
@@ -0,0 +1,27 @@
# Notifications interface pattern migration
Notifications uses the platform inbox/list-detail pattern for recipient work and the governed-operation pattern for delivery.
## Surfaces
- `notifications.route.notifications` remains the route identifier so existing Views keep working.
- `notifications.page.inbox` owns status filtering and notification selection.
- `notifications.page.detail` owns message, source, recipient, and read/acknowledgement state.
- `notifications.page.delivery` owns sanitized delivery-attempt evidence.
- `notifications.action.mark-read`, `notifications.action.acknowledge`, and `notifications.action.cancel` describe recipient actions on the selected record.
- `notifications.action.dispatch` describes the privileged bounded delivery operation.
- `notifications.settings.preferences` and `notifications.widget.summary` remain composed Settings and Dashboard surfaces.
The backend and WebUI manifests publish the same identifiers and hierarchy. Notifications does not introduce a navigation entry because the title-bar bell is the platform entry point.
## Consequences and recovery
Read and acknowledgement actions record durable recipient state. Cancellation is confirmed and is available only before an outcome becomes provider-accepted or otherwise terminal; it stops eligible local work and is not presented as remote recall. Dispatch is confirmed separately and processes at most 50 eligible tenant notifications per request. Accepted outcomes are not retried blindly.
Unavailable actions remain visible with explicit selection, permission, busy, terminal-state, and optional-Mail explanations. Personal preference drafts use the shared unsaved-change guard. The settings surface explains that in-product notifications continue when Mail is absent and that production email depends on the optional Mail capability.
Contextual help resolves through `govoplan-docs` when installed and through the hosted fallback otherwise. Delivery evidence remains sanitized and never exposes provider credentials or private sibling-module state.
## Optional boundaries
Producing modules continue to own notification meaning. Notifications owns durable recipient and delivery state and calls optional Mail, Portal, Tasks, Calendar, Scheduling, and Workflow Engine integrations only through declared capabilities and references.
+88 -4
View File
@@ -103,7 +103,23 @@ manifest = ModuleManifest(
documentation_types=("user",),
audience=("user",),
related_modules=("mail", "calendar", "scheduling", "workflow_engine"),
metadata={"kind": "reference"},
metadata={
"kind": "reference",
"help_contexts": [
"notifications.route.notifications",
"notifications.page.inbox",
"notifications.page.detail",
"notifications.settings.preferences",
"notifications.widget.summary",
"notifications.state.read-only",
],
"consequence_classes": {
"mark_read": "record the notification as read for the current recipient",
"acknowledge": "record explicit recipient acknowledgement in addition to read state",
"cancel": "stop an eligible notification before provider acceptance without claiming remote recall",
"update_preferences": "replace the current user's badge, channel, digest, and source-muting preferences",
},
},
),
DocumentationTopic(
id="notifications.delivery-operations",
@@ -113,7 +129,18 @@ manifest = ModuleManifest(
documentation_types=("admin",),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("mail", "audit", "ops"),
metadata={"kind": "reference"},
metadata={
"kind": "reference",
"help_contexts": [
"notifications.page.delivery",
"notifications.action.dispatch",
"notifications.state.delivery-unavailable",
],
"consequence_classes": {
"dispatch_pending": "attempt delivery for up to the requested number of eligible tenant notifications",
"inspect_attempts": "read sanitized provider, status, timing, and error evidence for the selected notification",
},
},
),
),
frontend=FrontendModule(
@@ -125,22 +152,79 @@ manifest = ModuleManifest(
component="NotificationCenterPage",
required_any=(READ_SCOPE,),
order=59,
surface_id="notifications.route.notifications",
),
),
view_surfaces=(
ViewSurface(
id="notifications.page.inbox",
module_id=MODULE_ID,
kind="section",
label="Notification inbox",
parent_id="notifications.route.notifications",
order=20,
),
ViewSurface(
id="notifications.page.detail",
module_id=MODULE_ID,
kind="section",
label="Notification details",
parent_id="notifications.route.notifications",
order=30,
),
ViewSurface(
id="notifications.page.delivery",
module_id=MODULE_ID,
kind="section",
label="Notification delivery evidence",
parent_id="notifications.page.detail",
order=40,
),
ViewSurface(
id="notifications.action.mark-read",
module_id=MODULE_ID,
kind="action",
label="Mark notification as read",
parent_id="notifications.page.detail",
order=50,
),
ViewSurface(
id="notifications.action.acknowledge",
module_id=MODULE_ID,
kind="action",
label="Acknowledge notification",
parent_id="notifications.page.detail",
order=60,
),
ViewSurface(
id="notifications.action.cancel",
module_id=MODULE_ID,
kind="action",
label="Cancel notification delivery",
parent_id="notifications.page.delivery",
order=70,
),
ViewSurface(
id="notifications.action.dispatch",
module_id=MODULE_ID,
kind="action",
label="Dispatch pending notifications",
parent_id="notifications.page.delivery",
order=80,
),
ViewSurface(
id="notifications.widget.summary",
module_id=MODULE_ID,
kind="section",
label="Notification summary widget",
order=30,
order=90,
),
ViewSurface(
id="notifications.settings.preferences",
module_id=MODULE_ID,
kind="section",
label="Notification preferences",
order=40,
order=100,
),
),
),
@@ -322,6 +322,10 @@ def update_notification(
notification.read_at = notification.read_at or now
notification.acknowledged_at = notification.acknowledged_at or now
elif payload.status == "cancelled":
if notification.status not in PENDING_STATUSES | {"paused"}:
raise NotificationError(
f"Notification cannot be cancelled from status {notification.status}"
)
notification.status = "cancelled"
notification.cancelled_at = notification.cancelled_at or now
if payload.metadata is not None:
@@ -0,0 +1,85 @@
from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_notifications.backend.manifest import get_manifest
REPO_ROOT = Path(__file__).resolve().parents[1]
class NotificationsInterfaceDocumentationContractTests(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]
expected = {
"notifications.page.inbox",
"notifications.page.detail",
"notifications.page.delivery",
"notifications.action.mark-read",
"notifications.action.acknowledge",
"notifications.action.cancel",
"notifications.action.dispatch",
"notifications.settings.preferences",
"notifications.widget.summary",
}
self.assertEqual(expected, set(surfaces))
self.assertEqual(
"notifications.route.notifications",
frontend.routes[0].surface_id, # type: ignore[union-attr]
)
self.assertEqual(
"notifications.route.notifications",
surfaces["notifications.page.inbox"].parent_id,
)
self.assertEqual(
"notifications.route.notifications",
surfaces["notifications.page.detail"].parent_id,
)
self.assertEqual(
"notifications.page.detail",
surfaces["notifications.page.delivery"].parent_id,
)
self.assertEqual(
"notifications.page.delivery",
surfaces["notifications.action.dispatch"].parent_id,
)
def test_help_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in get_manifest().documentation}
center = topics["notifications.center-and-preferences"]
delivery = topics["notifications.delivery-operations"]
self.assertIn("notifications.page.detail", center.metadata["help_contexts"])
self.assertIn("notifications.settings.preferences", center.metadata["help_contexts"])
self.assertIn("acknowledge", center.metadata["consequence_classes"])
self.assertIn("cancel", center.metadata["consequence_classes"])
self.assertIn("notifications.action.dispatch", delivery.metadata["help_contexts"])
self.assertIn("dispatch_pending", delivery.metadata["consequence_classes"])
def test_webui_uses_shared_consequence_and_draft_patterns(self) -> None:
center = (REPO_ROOT / "webui/src/features/notifications/NotificationCenterPage.tsx").read_text(encoding="utf-8")
settings = (REPO_ROOT / "webui/src/features/notifications/NotificationSettingsPanel.tsx").read_text(encoding="utf-8")
widget = (REPO_ROOT / "webui/src/features/notifications/NotificationSummaryWidget.tsx").read_text(encoding="utf-8")
for component in (
"ActionBlockerHint",
"ConfirmDialog",
"DocumentationHelpLink",
"SelectionList",
):
self.assertIn(component, center)
for component in (
"ActionBlockerHint",
"DocumentationHelpLink",
"ReferenceMultiSelect",
"useUnsavedDraftGuard",
):
self.assertIn(component, settings)
self.assertIn("DocumentationHelpLink", widget)
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -21,12 +21,14 @@ from govoplan_notifications.backend.db.models import NotificationDeliveryAttempt
from govoplan_notifications.backend.router import api_get_notification, api_list_notifications, api_notification_summary, api_update_notification
from govoplan_notifications.backend.schemas import NotificationCreateRequest, NotificationPreferencesUpdateRequest, NotificationUpdateRequest
from govoplan_notifications.backend.service import (
NotificationError,
create_notification,
deliver_pending,
notification_preferences_response,
notification_response,
notification_summary,
update_notification_preferences,
update_notification,
)
@@ -222,6 +224,37 @@ class NotificationServiceTests(unittest.TestCase):
session.commit()
enqueue.assert_not_called()
def test_cancellation_is_limited_to_locally_controllable_delivery_states(self) -> None:
with self.Session() as session:
notification = create_notification(
session,
tenant_id="tenant-1",
payload=self._inbox_payload(recipient_id="user-1", subject="Cancellation boundary"),
)
notification.status = "sent"
session.flush()
with self.assertRaisesRegex(
NotificationError,
"cannot be cancelled from status sent",
):
update_notification(
session,
tenant_id="tenant-1",
notification_id=notification.id,
payload=NotificationUpdateRequest(status="cancelled"),
)
notification.status = "queued"
cancelled = update_notification(
session,
tenant_id="tenant-1",
notification_id=notification.id,
payload=NotificationUpdateRequest(status="cancelled"),
)
self.assertEqual("cancelled", cancelled.status)
self.assertIsNotNone(cancelled.cancelled_at)
def test_action_url_accepts_only_application_relative_paths(self) -> None:
payload = self._inbox_payload(recipient_id="user-1", subject="Safe action")
safe = payload.model_copy(update={"action_url": "/calendar?event=event-1#details"})
@@ -8,11 +8,14 @@ const page = readFileSync(pagePath, "utf8");
const styles = readFileSync(stylesPath, "utf8");
assert.match(page, /SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(page, /<SelectionList label="Notifications" className="notifications-selection-list">/);
assert.match(page, /<SelectionList label="i18n:govoplan-notifications\.notifications" className="notifications-selection-list">/);
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selected\?\.id === notification\.id\}/);
assert.match(page, /className=\{`notifications-list-item \$\{notification\.read_at \? "is-read" : ""\}`\}/);
assert.doesNotMatch(page, /<button[\s\S]{0,160}notifications-list-item/);
assert.doesNotMatch(styles, /\.notifications-list-item:(?:hover|focus-visible)/);
assert.doesNotMatch(styles, /\.notifications-list-item\.is-selected/);
assert.match(page, /<ConfirmDialog[\s\S]*open=\{confirmingAction === "cancel"\}/);
assert.match(page, /<ConfirmDialog[\s\S]*open=\{confirmingAction === "dispatch"\}/);
assert.match(page, /disabledReason=\{cancelDisabledReason\}/);
console.log("Notification rows use the central selection-list contract.");
console.log("Notification center uses central selection, disabled-action, and confirmation contracts.");
@@ -1,8 +1,29 @@
import { useEffect, useMemo, useState } from "react";
import { Bell, Check, ExternalLink, RefreshCw, Send, XCircle } from "lucide-react";
import { AdminIconButton, Button, DismissibleAlert, SegmentedControl, SelectionList, SelectionListItem, StatusBadge, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
import {
ActionBlockerHint,
AdminIconButton,
Button,
ConfirmDialog,
DismissibleAlert,
DocumentationHelpLink,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
hasScope,
i18nMessage,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import { deliverPendingNotifications, listNotifications, updateNotification, type NotificationMessage } from "../../api/notifications";
import { safeNotificationActionUrl } from "../../security/actionUrl";
import {
NOTIFICATIONS_BLOCKER_LABELS,
NOTIFICATIONS_DELIVERY_DOCUMENTATION,
NOTIFICATIONS_DOCUMENTATION,
NOTIFICATIONS_I18N
} from "./interfacePatterns";
type StatusFilter =
| "all"
@@ -29,6 +50,8 @@ const statusFilters: StatusFilter[] = [
"cancelled"
];
const cancellableStatuses = new Set(["pending", "queued", "paused", "failed"]);
export default function NotificationCenterPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const [notifications, setNotifications] = useState<NotificationMessage[]>([]);
const [selectedId, setSelectedId] = useState("");
@@ -36,12 +59,28 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmingAction, setConfirmingAction] = useState<"cancel" | "dispatch" | null>(null);
const canRead = hasScope(auth, "notifications:notification:read");
const canWrite = hasScope(auth, "notifications:notification:write");
const canDispatch = hasScope(auth, "notifications:delivery:dispatch");
const selected = useMemo(() => notifications.find((item) => item.id === selectedId) || notifications[0] || null, [notifications, selectedId]);
const unreadCount = notifications.filter((item) => !item.read_at && !["cancelled", "skipped"].includes(item.status)).length;
const commonSelectionReason = busy
? NOTIFICATIONS_I18N.actionActive
: !selected
? NOTIFICATIONS_I18N.selectionRequired
: !canWrite
? NOTIFICATIONS_I18N.writePermissionRequired
: undefined;
const markReadDisabledReason = commonSelectionReason ?? (selected?.read_at ? NOTIFICATIONS_I18N.alreadyRead : undefined);
const acknowledgeDisabledReason = commonSelectionReason ?? (selected?.acknowledged_at ? NOTIFICATIONS_I18N.alreadyAcknowledged : undefined);
const cancelDisabledReason = commonSelectionReason ?? (selected && !cancellableStatuses.has(selected.status) ? NOTIFICATIONS_I18N.notCancellable : undefined);
const dispatchDisabledReason = busy
? NOTIFICATIONS_I18N.actionActive
: !canDispatch
? NOTIFICATIONS_I18N.dispatchPermissionRequired
: undefined;
useEffect(() => {
if (!canRead) {
@@ -68,43 +107,65 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
}
}
async function markSelected(status: "read" | "acknowledged" | "cancelled") {
if (!selected || !canWrite) return;
async function markSelected(status: "read" | "acknowledged" | "cancelled"): Promise<boolean> {
if (!selected || !canWrite) return false;
setBusy(true);
setError("");
try {
const next = await updateNotification(settings, selected.id, { status });
setNotifications((items) => items.map((item) => item.id === next.id ? next : item));
notifyNotificationsChanged();
return true;
} catch (err) {
setError(errorMessage(err));
return false;
} finally {
setBusy(false);
}
}
async function runDelivery() {
if (!canDispatch) return;
async function runDelivery(): Promise<boolean> {
if (!canDispatch) return false;
setBusy(true);
setError("");
try {
await deliverPendingNotifications(settings, 50);
await load();
notifyNotificationsChanged();
return true;
} catch (err) {
setError(errorMessage(err));
return false;
} finally {
setBusy(false);
}
}
async function confirmAction() {
const succeeded = confirmingAction === "cancel"
? await markSelected("cancelled")
: confirmingAction === "dispatch"
? await runDelivery()
: false;
if (succeeded) setConfirmingAction(null);
}
if (!canRead) {
return (
<main className="notifications-page">
<div className="notifications-empty-state">
<Bell size={22} />
<h1>Notifications</h1>
<p>You do not have permission to read notifications in this tenant.</p>
<div className="notifications-permission-state">
<ActionBlockerHint
tone="warning"
reason={{
summary: "i18n:govoplan-notifications.read_blocked_summary",
details: NOTIFICATIONS_I18N.readPermissionRequired,
requiredAction: "i18n:govoplan-notifications.permission_action",
actor: "i18n:govoplan-notifications.permission_actor",
target: "i18n:govoplan-notifications.permission_target"
}}
labels={NOTIFICATIONS_BLOCKER_LABELS}
documentation={NOTIFICATIONS_DOCUMENTATION}
/>
</div>
</main>
);
@@ -117,24 +178,30 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
<div className="notifications-sidebar-bar">
<div className="notifications-title">
<Bell size={17} />
<strong>Notifications</strong>
<strong>i18n:govoplan-notifications.notifications</strong>
{unreadCount > 0 ? <span className="notifications-count">{unreadCount}</span> : null}
</div>
<AdminIconButton label="Refresh" icon={<RefreshCw size={16} aria-hidden="true" />} onClick={() => void load()} disabled={loading || busy} />
<AdminIconButton
label="i18n:govoplan-notifications.refresh"
icon={<RefreshCw size={16} aria-hidden="true" />}
onClick={() => void load()}
disabled={loading || busy}
disabledReason={loading ? NOTIFICATIONS_I18N.loading : busy ? NOTIFICATIONS_I18N.actionActive : undefined}
/>
</div>
<SegmentedControl
className="notifications-status-filter"
options={statusFilters.map((status) => ({ id: status, label: status }))}
options={statusFilters.map((status) => ({ id: status, label: statusLabel(status) }))}
value={statusFilter}
onChange={setStatusFilter}
ariaLabel="Notification status"
ariaLabel="i18n:govoplan-notifications.notification_status"
width="fill"
/>
<div className="notifications-list">
{loading ? <div className="notifications-note">Loading notifications</div> : null}
{!loading && notifications.length === 0 ? <div className="notifications-note">No notifications in this view.</div> : null}
{loading ? <div className="notifications-note">i18n:govoplan-notifications.loading_notifications</div> : null}
{!loading && notifications.length === 0 ? <div className="notifications-note">i18n:govoplan-notifications.no_notifications</div> : null}
{notifications.length > 0 ? (
<SelectionList label="Notifications" className="notifications-selection-list">
<SelectionList label="i18n:govoplan-notifications.notifications" className="notifications-selection-list">
{notifications.map((notification) => (
<SelectionListItem
key={notification.id}
@@ -160,37 +227,70 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
<div className="notifications-topbar">
<div className="notifications-title-line">
<Bell size={18} />
<strong>{selected?.subject || selected?.event_kind || "Notification center"}</strong>
<strong>{selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.surface.center"}</strong>
</div>
<div className="notifications-actions">
<Button onClick={() => void markSelected("read")} disabled={!selected || busy || !canWrite}>
<Check size={16} /> Mark read
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
<Button onClick={() => void markSelected("read")} disabled={Boolean(markReadDisabledReason)} disabledReason={markReadDisabledReason}>
<Check size={16} /> i18n:govoplan-notifications.mark_read
</Button>
<Button onClick={() => void markSelected("acknowledged")} disabled={!selected || busy || !canWrite}>
<Check size={16} /> Acknowledge
<Button onClick={() => void markSelected("acknowledged")} disabled={Boolean(acknowledgeDisabledReason)} disabledReason={acknowledgeDisabledReason}>
<Check size={16} /> i18n:govoplan-notifications.acknowledge
</Button>
<Button onClick={() => void markSelected("cancelled")} disabled={!selected || busy || !canWrite}>
<XCircle size={16} /> Cancel
<Button variant="danger" onClick={() => setConfirmingAction("cancel")} disabled={Boolean(cancelDisabledReason)} disabledReason={cancelDisabledReason}>
<XCircle size={16} /> i18n:govoplan-notifications.cancel_delivery
</Button>
{canDispatch ? (
<Button onClick={() => void runDelivery()} disabled={busy}>
<Send size={16} /> Dispatch pending
<Button onClick={() => setConfirmingAction("dispatch")} disabled={Boolean(dispatchDisabledReason)} disabledReason={dispatchDisabledReason}>
<Send size={16} /> i18n:govoplan-notifications.dispatch_pending
</Button>
) : null}
</div>
</div>
{error ? <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert> : null}
{selected && !canWrite ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "i18n:govoplan-notifications.actions_read_only_summary",
details: NOTIFICATIONS_I18N.writePermissionRequired,
requiredAction: "i18n:govoplan-notifications.permission_action",
actor: "i18n:govoplan-notifications.permission_actor",
target: "i18n:govoplan-notifications.permission_target"
}}
labels={NOTIFICATIONS_BLOCKER_LABELS}
documentation={NOTIFICATIONS_DOCUMENTATION}
/>
) : null}
{selected ? <NotificationDetails notification={selected} /> : (
<div className="notifications-empty-state">
<Bell size={22} />
<h1>Notifications</h1>
<p>Select a notification to inspect delivery state, source context, and content.</p>
<h1>i18n:govoplan-notifications.notifications</h1>
<p>i18n:govoplan-notifications.select_notification_help</p>
</div>
)}
</section>
</div>
<ConfirmDialog
open={confirmingAction === "cancel"}
title="i18n:govoplan-notifications.cancel_delivery_title"
message={i18nMessage("i18n:govoplan-notifications.cancel_delivery_message", { value0: selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.selected_notification" })}
confirmLabel="i18n:govoplan-notifications.cancel_delivery"
tone="danger"
busy={busy}
onCancel={() => setConfirmingAction(null)}
onConfirm={() => void confirmAction()}
/>
<ConfirmDialog
open={confirmingAction === "dispatch"}
title="i18n:govoplan-notifications.dispatch_pending_title"
message="i18n:govoplan-notifications.dispatch_pending_message"
confirmLabel="i18n:govoplan-notifications.dispatch_pending"
busy={busy}
onCancel={() => setConfirmingAction(null)}
onConfirm={() => void confirmAction()}
/>
</main>
);
}
@@ -206,32 +306,35 @@ function NotificationDetails({ notification }: { notification: NotificationMessa
<span>{formatDate(notification.created_at)}</span>
</div>
<h1>{notification.subject || notification.event_kind}</h1>
{notification.body_text ? <p>{notification.body_text}</p> : <p className="muted">No message body was provided.</p>}
{notification.body_text ? <p>{notification.body_text}</p> : <p className="muted">i18n:govoplan-notifications.no_message_body</p>}
{actionUrl ? (
<a className="notifications-action-link" href={actionUrl}>
<ExternalLink size={16} /> Open related item
<ExternalLink size={16} /> i18n:govoplan-notifications.open_related_item
</a>
) : null}
</section>
<section className="notifications-properties">
<h2>Source and delivery</h2>
<h2>i18n:govoplan-notifications.source_and_delivery</h2>
<dl>
<div><dt>Source</dt><dd>{notification.source_module} / {notification.source_resource_type}</dd></div>
<div><dt>Resource</dt><dd>{notification.source_resource_id || "None"}</dd></div>
<div><dt>Recipient</dt><dd>{notification.recipient_label || notification.recipient || notification.recipient_id || "None"}</dd></div>
<div><dt>Priority</dt><dd>{notification.priority}</dd></div>
<div><dt>Queued</dt><dd>{formatDate(notification.queued_at)}</dd></div>
<div><dt>Sent</dt><dd>{formatDate(notification.sent_at)}</dd></div>
<div><dt>Read</dt><dd>{formatDate(notification.read_at)}</dd></div>
<div><dt>Attempts</dt><dd>{notification.attempt_count}</dd></div>
<div><dt>i18n:govoplan-notifications.source</dt><dd>{notification.source_module} / {notification.source_resource_type}</dd></div>
<div><dt>i18n:govoplan-notifications.resource</dt><dd>{notification.source_resource_id || "i18n:govoplan-notifications.none"}</dd></div>
<div><dt>i18n:govoplan-notifications.recipient</dt><dd>{notification.recipient_label || notification.recipient || notification.recipient_id || "i18n:govoplan-notifications.none"}</dd></div>
<div><dt>i18n:govoplan-notifications.priority</dt><dd>{notification.priority}</dd></div>
<div><dt>i18n:govoplan-notifications.queued</dt><dd>{formatDate(notification.queued_at)}</dd></div>
<div><dt>i18n:govoplan-notifications.sent</dt><dd>{formatDate(notification.sent_at)}</dd></div>
<div><dt>i18n:govoplan-notifications.read</dt><dd>{formatDate(notification.read_at)}</dd></div>
<div><dt>i18n:govoplan-notifications.attempts</dt><dd>{notification.attempt_count}</dd></div>
</dl>
{notification.last_error ? <p className="notifications-error">{notification.last_error}</p> : null}
</section>
<section className="notifications-attempts">
<h2>Delivery attempts</h2>
{notification.attempts.length === 0 ? <p className="muted">No delivery attempt has been recorded yet.</p> : null}
<div className="notifications-section-heading">
<h2>i18n:govoplan-notifications.delivery_attempts</h2>
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
</div>
{notification.attempts.length === 0 ? <p className="muted">i18n:govoplan-notifications.no_delivery_attempt</p> : null}
{notification.attempts.map((attempt) => (
<div className="notifications-attempt" key={attempt.id}>
<strong>{attempt.provider || attempt.channel}</strong>
@@ -246,11 +349,15 @@ function NotificationDetails({ notification }: { notification: NotificationMessa
}
function formatStatus(value: string): string {
return value.replace(/_/g, " ");
return statusLabel(value);
}
function statusLabel(value: string): string {
return `i18n:govoplan-notifications.status.${value.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}`;
}
function formatDate(value?: string | null): string {
if (!value) return "Not set";
if (!value) return "i18n:govoplan-notifications.not_set";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
@@ -260,7 +367,7 @@ function formatDate(value?: string | null): string {
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Request failed";
return error instanceof Error ? error.message : "i18n:govoplan-notifications.request_failed";
}
function notifyNotificationsChanged(): void {
@@ -1,24 +1,35 @@
import { useEffect, useMemo, useState } from "react";
import { Mail, Save } from "lucide-react";
import {
ActionBlockerHint,
Button,
Card,
DismissibleAlert,
DocumentationHelpLink,
FormField,
ReferenceMultiSelect,
ToggleSwitch,
hasScope,
i18nMessage,
isViewSurfaceVisible,
moduleViewSurfaceId,
platformModuleReferenceProvider,
useEffectiveView,
usePlatformLanguage,
usePlatformModules,
useUnsavedDraftGuard,
useViewSurfaces,
type ApiSettings,
type AuthInfo,
type ReferenceOption
} from "@govoplan/core-webui";
import { getNotificationPreferences, updateNotificationPreferences, type NotificationPreferences } from "../../api/notifications";
import {
NOTIFICATIONS_BLOCKER_LABELS,
NOTIFICATIONS_DELIVERY_DOCUMENTATION,
NOTIFICATIONS_DOCUMENTATION,
NOTIFICATIONS_I18N
} from "./interfacePatterns";
type Draft = Pick<NotificationPreferences, "show_unread_badge" | "email_enabled" | "email_digest_enabled"> & {
muted_source_modules: string[];
@@ -42,6 +53,8 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [messageTone, setMessageTone] = useState<"success" | "warning">("success");
const canWrite = hasScope(auth, "notifications:notification:write");
const mailAvailable = modules.some((module) => module.id === "mail");
const dirty = useMemo(() => {
if (!loaded) return false;
@@ -64,8 +77,8 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
label: translateText(module.label),
description: [
module.id,
`version ${module.version}`,
visible ? null : "Hidden by the active view"
i18nMessage("i18n:govoplan-notifications.version_value", { value0: module.version }),
visible ? null : translateText("i18n:govoplan-notifications.hidden_by_view")
].filter(Boolean).join(" · "),
kind: "module",
availability: visible ? "available" : "unavailable",
@@ -84,6 +97,22 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
[moduleOptions, settings]
);
function resetDraft() {
if (!loaded) return;
setDraft({
show_unread_badge: loaded.show_unread_badge,
email_enabled: loaded.email_enabled,
email_digest_enabled: loaded.email_digest_enabled,
muted_source_modules: loaded.muted_source_modules
});
}
useUnsavedDraftGuard({
dirty,
onSave: savePreferences,
onDiscard: resetDraft
});
useEffect(() => {
void loadPreferences();
}, [auth.user.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
@@ -102,13 +131,14 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
});
} catch (error) {
setMessageTone("warning");
setMessage(error instanceof Error ? error.message : "Loading notification preferences failed");
setMessage(error instanceof Error ? error.message : translateText("i18n:govoplan-notifications.preferences_load_failed"));
} finally {
setLoading(false);
}
}
async function savePreferences() {
async function savePreferences(): Promise<boolean> {
if (!canWrite) return false;
setSaving(true);
setMessage("");
try {
@@ -127,65 +157,112 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
});
window.dispatchEvent(new CustomEvent("govoplan:notifications-changed"));
setMessageTone("success");
setMessage("Notification preferences saved.");
setMessage("i18n:govoplan-notifications.preferences_saved");
return true;
} catch (error) {
setMessageTone("warning");
setMessage(error instanceof Error ? error.message : "Saving notification preferences failed");
setMessage(error instanceof Error ? error.message : translateText("i18n:govoplan-notifications.preferences_save_failed"));
return false;
} finally {
setSaving(false);
}
}
const saveDisabledReason = loading
? NOTIFICATIONS_I18N.loading
: saving
? NOTIFICATIONS_I18N.saving
: !canWrite
? NOTIFICATIONS_I18N.writePermissionRequired
: !dirty
? NOTIFICATIONS_I18N.noChanges
: undefined;
const preferenceControlsDisabled = loading || saving || !canWrite;
const emailToggleDisabled = preferenceControlsDisabled || (!mailAvailable && !draft.email_enabled);
const digestToggleDisabled = preferenceControlsDisabled || !mailAvailable || !draft.email_enabled;
return (
<div className="dashboard-grid settings-dashboard-grid notifications-settings-panel">
<Card title="Notifications">
<div className="notifications-settings-documentation">
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
</div>
{!canWrite ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "i18n:govoplan-notifications.preferences_read_only_summary",
details: NOTIFICATIONS_I18N.writePermissionRequired,
requiredAction: "i18n:govoplan-notifications.permission_action",
actor: "i18n:govoplan-notifications.permission_actor",
target: "i18n:govoplan-notifications.permission_target"
}}
labels={NOTIFICATIONS_BLOCKER_LABELS}
documentation={NOTIFICATIONS_DOCUMENTATION}
/>
) : null}
<Card title="i18n:govoplan-notifications.notifications">
<div className="form-grid">
<ToggleSwitch
label="Unread badge"
help="Show unread notification counts in the top bar."
label="i18n:govoplan-notifications.unread_badge"
help="i18n:govoplan-notifications.unread_badge_help"
checked={draft.show_unread_badge}
disabled={loading}
disabled={preferenceControlsDisabled}
onChange={(value) => setDraft((current) => ({ ...current, show_unread_badge: value }))}
/>
<FormField label="Muted source modules" help="Choose active modules. Saved modules hidden by the current view remain visible here and are not discarded.">
<FormField label="i18n:govoplan-notifications.muted_sources" help="i18n:govoplan-notifications.muted_sources_help">
<ReferenceMultiSelect
values={draft.muted_source_modules}
onChange={(muted_source_modules) =>
setDraft((current) => ({ ...current, muted_source_modules }))
}
provider={moduleProvider}
aria-label="Muted source modules"
placeholder="Add a source module"
emptyText="No visible modules match."
disabled={loading}
aria-label="i18n:govoplan-notifications.muted_sources"
placeholder="i18n:govoplan-notifications.add_source_module"
emptyText="i18n:govoplan-notifications.no_visible_modules"
disabled={preferenceControlsDisabled}
/>
</FormField>
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => void savePreferences()} disabled={loading || saving || !dirty}>
<Save size={16} /> {saving ? "Saving" : "Save preferences"}
<Button variant="primary" onClick={() => void savePreferences()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>
<Save size={16} /> {saving ? "i18n:govoplan-notifications.saving" : "i18n:govoplan-notifications.save_preferences"}
</Button>
</div>
{message ? <DismissibleAlert tone={messageTone} resetKey={message} floating>{message}</DismissibleAlert> : null}
</div>
</Card>
<Card title="Delivery">
<Card title="i18n:govoplan-notifications.delivery">
<div className="form-grid">
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
<div className="notifications-settings-inline-title">
<Mail size={16} />
<strong>Email notifications</strong>
<strong>i18n:govoplan-notifications.email_notifications</strong>
</div>
{!mailAvailable ? (
<ActionBlockerHint
tone="info"
reason={{
summary: "i18n:govoplan-notifications.mail_unavailable_summary",
details: NOTIFICATIONS_I18N.mailUnavailable,
requiredAction: "i18n:govoplan-notifications.mail_unavailable_action",
actor: "i18n:govoplan-notifications.mail_unavailable_actor",
target: "i18n:govoplan-notifications.mail_unavailable_target"
}}
labels={NOTIFICATIONS_BLOCKER_LABELS}
documentation={NOTIFICATIONS_DELIVERY_DOCUMENTATION}
/>
) : null}
<ToggleSwitch
label="Email notifications"
help="Deliver production email through the optional Mail module. In-app notifications continue to work when Mail is unavailable."
label="i18n:govoplan-notifications.email_notifications"
help="i18n:govoplan-notifications.email_notifications_help"
checked={draft.email_enabled}
disabled={loading}
disabled={emailToggleDisabled}
onChange={(value) => setDraft((current) => ({ ...current, email_enabled: value, email_digest_enabled: value ? current.email_digest_enabled : false }))}
/>
<ToggleSwitch
label="Email digest"
help="Batch eligible notifications into summary messages when delivery rules support it."
label="i18n:govoplan-notifications.email_digest"
help="i18n:govoplan-notifications.email_digest_help"
checked={draft.email_digest_enabled}
disabled={loading || !draft.email_enabled}
disabled={digestToggleDisabled}
onChange={(value) => setDraft((current) => ({ ...current, email_digest_enabled: value }))}
/>
</div>
@@ -1,13 +1,16 @@
import { Link } from "react-router";
import {
DismissibleAlert,
DocumentationHelpLink,
LoadingFrame,
MetricCard,
adminErrorMessage,
i18nMessage,
type ApiSettings,
type DashboardWidgetConfiguration,
useSharedNotificationSummary
} from "@govoplan/core-webui";
import { NOTIFICATIONS_DOCUMENTATION } from "./interfacePatterns";
export default function NotificationSummaryWidget({
settings,
@@ -31,7 +34,7 @@ export default function NotificationSummaryWidget({
const error = state.error ? adminErrorMessage(state.error) : "";
return (
<LoadingFrame loading={state.loading} label="Loading notification summary">
<LoadingFrame loading={state.loading} label="i18n:govoplan-notifications.loading_summary">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
@@ -39,35 +42,36 @@ export default function NotificationSummaryWidget({
)}
<div className="metric-grid inside dashboard-widget-metrics">
<MetricCard
label="Unread"
label="i18n:govoplan-notifications.unread"
value={summary?.unread ?? 0}
tone={summary?.unread ? "info" : "good"}
detail={`${summary?.total ?? 0} total`}
detail={i18nMessage("i18n:govoplan-notifications.value_total", { value0: summary?.total ?? 0 })}
/>
{showDeliveryState && (
<MetricCard
label="Pending"
label="i18n:govoplan-notifications.pending"
value={summary?.pending ?? 0}
tone={summary?.pending ? "warning" : "good"}
detail="Awaiting delivery"
detail="i18n:govoplan-notifications.awaiting_delivery"
/>
)}
{showDeliveryState && (
<MetricCard
label="Failed"
label="i18n:govoplan-notifications.failed"
value={summary?.failed ?? 0}
tone={summary?.failed ? "danger" : "good"}
detail="Delivery failures"
detail="i18n:govoplan-notifications.delivery_failures"
/>
)}
</div>
{showCenterLink && (
<div className="notifications-widget-actions">
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
{showCenterLink && (
<Link className="btn btn-secondary" to="/notifications">
Open notification center
i18n:govoplan-notifications.open_center
</Link>
</div>
)}
</div>
</LoadingFrame>
);
}
@@ -0,0 +1,32 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const NOTIFICATIONS_DOCUMENTATION = {
topicId: "notifications.center-and-preferences",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const NOTIFICATIONS_DELIVERY_DOCUMENTATION = {
topicId: "notifications.delivery-operations",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const NOTIFICATIONS_I18N = {
loading: "i18n:govoplan-notifications.reason.loading",
saving: "i18n:govoplan-notifications.reason.saving",
actionActive: "i18n:govoplan-notifications.reason.action_active",
selectionRequired: "i18n:govoplan-notifications.reason.selection_required",
readPermissionRequired: "i18n:govoplan-notifications.reason.read_permission_required",
writePermissionRequired: "i18n:govoplan-notifications.reason.write_permission_required",
dispatchPermissionRequired: "i18n:govoplan-notifications.reason.dispatch_permission_required",
alreadyRead: "i18n:govoplan-notifications.reason.already_read",
alreadyAcknowledged: "i18n:govoplan-notifications.reason.already_acknowledged",
notCancellable: "i18n:govoplan-notifications.reason.not_cancellable",
noChanges: "i18n:govoplan-notifications.reason.no_changes",
mailUnavailable: "i18n:govoplan-notifications.reason.mail_unavailable"
} as const;
export const NOTIFICATIONS_BLOCKER_LABELS = {
requiredAction: "i18n:govoplan-notifications.blocker.required_action",
actor: "i18n:govoplan-notifications.blocker.actor",
target: "i18n:govoplan-notifications.blocker.target"
} as const;
+223 -3
View File
@@ -1,4 +1,224 @@
export const generatedTranslations = {
en: {},
de: {}
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
en: {
"i18n:govoplan-notifications.surface.center": "Notification center",
"i18n:govoplan-notifications.surface.inbox": "Notification inbox",
"i18n:govoplan-notifications.surface.detail": "Notification details",
"i18n:govoplan-notifications.surface.delivery": "Notification delivery evidence",
"i18n:govoplan-notifications.surface.mark_read": "Mark notification as read",
"i18n:govoplan-notifications.surface.acknowledge": "Acknowledge notification",
"i18n:govoplan-notifications.surface.cancel": "Cancel notification delivery",
"i18n:govoplan-notifications.surface.dispatch": "Dispatch pending notifications",
"i18n:govoplan-notifications.surface.widget_summary": "Notification summary widget",
"i18n:govoplan-notifications.surface.preferences": "Notification preferences",
"i18n:govoplan-notifications.widget_description": "Unread notifications and delivery state.",
"i18n:govoplan-notifications.communication": "Communication",
"i18n:govoplan-notifications.show_delivery_state": "Show delivery state",
"i18n:govoplan-notifications.show_delivery_state_help": "Include pending and failed delivery counts.",
"i18n:govoplan-notifications.reason.loading": "Notifications are loading.",
"i18n:govoplan-notifications.reason.saving": "Notification preferences are being saved.",
"i18n:govoplan-notifications.reason.action_active": "A notification action is already running.",
"i18n:govoplan-notifications.reason.selection_required": "Select a notification first.",
"i18n:govoplan-notifications.reason.read_permission_required": "Notification-read permission is required.",
"i18n:govoplan-notifications.reason.write_permission_required": "Notification-write permission is required.",
"i18n:govoplan-notifications.reason.dispatch_permission_required": "Notification-dispatch permission is required.",
"i18n:govoplan-notifications.reason.already_read": "This notification is already marked as read.",
"i18n:govoplan-notifications.reason.already_acknowledged": "This notification is already acknowledged.",
"i18n:govoplan-notifications.reason.not_cancellable": "Only pending, queued, paused, or failed delivery can be cancelled safely.",
"i18n:govoplan-notifications.reason.no_changes": "No preference changes need to be saved.",
"i18n:govoplan-notifications.reason.mail_unavailable": "The optional Mail module is not enabled. In-product notifications remain available.",
"i18n:govoplan-notifications.blocker.required_action": "Required action",
"i18n:govoplan-notifications.blocker.actor": "Who can fix it",
"i18n:govoplan-notifications.blocker.target": "Where to go",
"i18n:govoplan-notifications.read_blocked_summary": "The notification center is unavailable.",
"i18n:govoplan-notifications.actions_read_only_summary": "Notification actions are read-only.",
"i18n:govoplan-notifications.preferences_read_only_summary": "Notification preferences are read-only.",
"i18n:govoplan-notifications.permission_action": "Ask a tenant administrator to grant the required Notifications permission.",
"i18n:govoplan-notifications.permission_actor": "Tenant administrator or access manager",
"i18n:govoplan-notifications.permission_target": "Administration > Access > Roles",
"i18n:govoplan-notifications.mail_unavailable_summary": "Email delivery is unavailable.",
"i18n:govoplan-notifications.mail_unavailable_action": "Enable and configure Mail before turning on production email notifications.",
"i18n:govoplan-notifications.mail_unavailable_actor": "System or tenant administrator",
"i18n:govoplan-notifications.mail_unavailable_target": "Administration > Modules and Mail settings",
"i18n:govoplan-notifications.refresh": "Refresh notifications",
"i18n:govoplan-notifications.notifications": "Notifications",
"i18n:govoplan-notifications.notification_status": "Notification status",
"i18n:govoplan-notifications.loading_notifications": "Loading notifications",
"i18n:govoplan-notifications.no_notifications": "No notifications in this view.",
"i18n:govoplan-notifications.mark_read": "Mark read",
"i18n:govoplan-notifications.acknowledge": "Acknowledge",
"i18n:govoplan-notifications.cancel_delivery": "Cancel delivery",
"i18n:govoplan-notifications.dispatch_pending": "Dispatch pending",
"i18n:govoplan-notifications.cancel_delivery_title": "Cancel notification delivery",
"i18n:govoplan-notifications.cancel_delivery_message": "Cancel delivery of {value0}. This stops eligible local delivery work but cannot recall provider-accepted messages.",
"i18n:govoplan-notifications.dispatch_pending_title": "Dispatch pending notifications",
"i18n:govoplan-notifications.dispatch_pending_message": "Attempt delivery for up to 50 eligible notifications in this tenant. Provider-accepted outcomes will not be repeated automatically.",
"i18n:govoplan-notifications.selected_notification": "the selected notification",
"i18n:govoplan-notifications.delivery_attempts": "Delivery attempts",
"i18n:govoplan-notifications.select_notification_help": "Select a notification to inspect delivery state, source context, and content.",
"i18n:govoplan-notifications.no_message_body": "No message body was provided.",
"i18n:govoplan-notifications.open_related_item": "Open related item",
"i18n:govoplan-notifications.source_and_delivery": "Source and delivery",
"i18n:govoplan-notifications.source": "Source",
"i18n:govoplan-notifications.resource": "Resource",
"i18n:govoplan-notifications.recipient": "Recipient",
"i18n:govoplan-notifications.priority": "Priority",
"i18n:govoplan-notifications.queued": "Queued",
"i18n:govoplan-notifications.sent": "Sent",
"i18n:govoplan-notifications.read": "Read",
"i18n:govoplan-notifications.attempts": "Attempts",
"i18n:govoplan-notifications.none": "None",
"i18n:govoplan-notifications.no_delivery_attempt": "No delivery attempt has been recorded yet.",
"i18n:govoplan-notifications.not_set": "Not set",
"i18n:govoplan-notifications.request_failed": "Request failed",
"i18n:govoplan-notifications.preferences_saved": "Notification preferences saved.",
"i18n:govoplan-notifications.preferences_load_failed": "Loading notification preferences failed.",
"i18n:govoplan-notifications.preferences_save_failed": "Saving notification preferences failed.",
"i18n:govoplan-notifications.save_preferences": "Save preferences",
"i18n:govoplan-notifications.saving": "Saving",
"i18n:govoplan-notifications.version_value": "version {value0}",
"i18n:govoplan-notifications.hidden_by_view": "Hidden by the active view",
"i18n:govoplan-notifications.unread_badge": "Unread badge",
"i18n:govoplan-notifications.unread_badge_help": "Show unread notification counts in the top bar.",
"i18n:govoplan-notifications.muted_sources": "Muted source modules",
"i18n:govoplan-notifications.muted_sources_help": "Choose active modules. Saved modules hidden by the current view remain visible here and are not discarded.",
"i18n:govoplan-notifications.add_source_module": "Add a source module",
"i18n:govoplan-notifications.no_visible_modules": "No visible modules match.",
"i18n:govoplan-notifications.delivery": "Delivery",
"i18n:govoplan-notifications.email_notifications": "Email notifications",
"i18n:govoplan-notifications.email_notifications_help": "Deliver production email through the optional Mail module. In-product notifications continue to work when Mail is unavailable.",
"i18n:govoplan-notifications.email_digest": "Email digest",
"i18n:govoplan-notifications.email_digest_help": "Batch eligible notifications into summary messages when delivery rules support it.",
"i18n:govoplan-notifications.loading_summary": "Loading notification summary",
"i18n:govoplan-notifications.unread": "Unread",
"i18n:govoplan-notifications.pending": "Pending",
"i18n:govoplan-notifications.failed": "Failed",
"i18n:govoplan-notifications.value_total": "{value0} total",
"i18n:govoplan-notifications.awaiting_delivery": "Awaiting delivery",
"i18n:govoplan-notifications.delivery_failures": "Delivery failures",
"i18n:govoplan-notifications.open_center": "Open notification center",
"i18n:govoplan-notifications.status.all": "All",
"i18n:govoplan-notifications.status.pending": "Pending",
"i18n:govoplan-notifications.status.queued": "Queued",
"i18n:govoplan-notifications.status.sending": "Sending",
"i18n:govoplan-notifications.status.accepted": "Accepted",
"i18n:govoplan-notifications.status.paused": "Paused",
"i18n:govoplan-notifications.status.sent": "Sent",
"i18n:govoplan-notifications.status.failed": "Failed",
"i18n:govoplan-notifications.status.skipped": "Skipped",
"i18n:govoplan-notifications.status.cancelled": "Cancelled",
"i18n:govoplan-notifications.status.read": "Read",
"i18n:govoplan-notifications.status.acknowledged": "Acknowledged"
},
de: {
"i18n:govoplan-notifications.surface.center": "Benachrichtigungszentrale",
"i18n:govoplan-notifications.surface.inbox": "Benachrichtigungseingang",
"i18n:govoplan-notifications.surface.detail": "Benachrichtigungsdetails",
"i18n:govoplan-notifications.surface.delivery": "Zustellnachweise für Benachrichtigungen",
"i18n:govoplan-notifications.surface.mark_read": "Benachrichtigung als gelesen markieren",
"i18n:govoplan-notifications.surface.acknowledge": "Benachrichtigung bestätigen",
"i18n:govoplan-notifications.surface.cancel": "Zustellung der Benachrichtigung abbrechen",
"i18n:govoplan-notifications.surface.dispatch": "Ausstehende Benachrichtigungen zustellen",
"i18n:govoplan-notifications.surface.widget_summary": "Übersicht der Benachrichtigungen",
"i18n:govoplan-notifications.surface.preferences": "Benachrichtigungseinstellungen",
"i18n:govoplan-notifications.widget_description": "Ungelesene Benachrichtigungen und Zustellstatus.",
"i18n:govoplan-notifications.communication": "Kommunikation",
"i18n:govoplan-notifications.show_delivery_state": "Zustellstatus anzeigen",
"i18n:govoplan-notifications.show_delivery_state_help": "Ausstehende und fehlgeschlagene Zustellungen einbeziehen.",
"i18n:govoplan-notifications.reason.loading": "Benachrichtigungen werden geladen.",
"i18n:govoplan-notifications.reason.saving": "Benachrichtigungseinstellungen werden gespeichert.",
"i18n:govoplan-notifications.reason.action_active": "Eine Benachrichtigungsaktion wird bereits ausgeführt.",
"i18n:govoplan-notifications.reason.selection_required": "Wählen Sie zuerst eine Benachrichtigung aus.",
"i18n:govoplan-notifications.reason.read_permission_required": "Die Berechtigung zum Lesen von Benachrichtigungen ist erforderlich.",
"i18n:govoplan-notifications.reason.write_permission_required": "Die Berechtigung zum Bearbeiten von Benachrichtigungen ist erforderlich.",
"i18n:govoplan-notifications.reason.dispatch_permission_required": "Die Berechtigung zum Zustellen von Benachrichtigungen ist erforderlich.",
"i18n:govoplan-notifications.reason.already_read": "Diese Benachrichtigung ist bereits als gelesen markiert.",
"i18n:govoplan-notifications.reason.already_acknowledged": "Diese Benachrichtigung wurde bereits bestätigt.",
"i18n:govoplan-notifications.reason.not_cancellable": "Nur ausstehende, eingereihte, pausierte oder fehlgeschlagene Zustellungen können sicher abgebrochen werden.",
"i18n:govoplan-notifications.reason.no_changes": "Es müssen keine geänderten Einstellungen gespeichert werden.",
"i18n:govoplan-notifications.reason.mail_unavailable": "Das optionale Mail-Modul ist nicht aktiviert. Benachrichtigungen in GovOPlaN bleiben verfügbar.",
"i18n:govoplan-notifications.blocker.required_action": "Erforderliche Aktion",
"i18n:govoplan-notifications.blocker.actor": "Zuständige Stelle",
"i18n:govoplan-notifications.blocker.target": "Ziel",
"i18n:govoplan-notifications.read_blocked_summary": "Die Benachrichtigungszentrale ist nicht verfügbar.",
"i18n:govoplan-notifications.actions_read_only_summary": "Benachrichtigungsaktionen sind schreibgeschützt.",
"i18n:govoplan-notifications.preferences_read_only_summary": "Benachrichtigungseinstellungen sind schreibgeschützt.",
"i18n:govoplan-notifications.permission_action": "Bitten Sie eine mandantenverwaltende Person, die erforderliche Benachrichtigungsberechtigung zu erteilen.",
"i18n:govoplan-notifications.permission_actor": "Mandantenadministration oder Zugriffsverwaltung",
"i18n:govoplan-notifications.permission_target": "Administration > Zugriff > Rollen",
"i18n:govoplan-notifications.mail_unavailable_summary": "E-Mail-Zustellung ist nicht verfügbar.",
"i18n:govoplan-notifications.mail_unavailable_action": "Aktivieren und konfigurieren Sie Mail, bevor produktive E-Mail-Benachrichtigungen eingeschaltet werden.",
"i18n:govoplan-notifications.mail_unavailable_actor": "System- oder Mandantenadministration",
"i18n:govoplan-notifications.mail_unavailable_target": "Administration > Module und Mail-Einstellungen",
"i18n:govoplan-notifications.refresh": "Benachrichtigungen aktualisieren",
"i18n:govoplan-notifications.notifications": "Benachrichtigungen",
"i18n:govoplan-notifications.notification_status": "Benachrichtigungsstatus",
"i18n:govoplan-notifications.loading_notifications": "Benachrichtigungen werden geladen",
"i18n:govoplan-notifications.no_notifications": "In dieser Ansicht gibt es keine Benachrichtigungen.",
"i18n:govoplan-notifications.mark_read": "Als gelesen markieren",
"i18n:govoplan-notifications.acknowledge": "Bestätigen",
"i18n:govoplan-notifications.cancel_delivery": "Zustellung abbrechen",
"i18n:govoplan-notifications.dispatch_pending": "Ausstehende zustellen",
"i18n:govoplan-notifications.cancel_delivery_title": "Zustellung der Benachrichtigung abbrechen",
"i18n:govoplan-notifications.cancel_delivery_message": "Zustellung von {value0} abbrechen. Dies stoppt geeignete lokale Zustellarbeit, kann aber von einem Anbieter angenommene Nachrichten nicht zurückrufen.",
"i18n:govoplan-notifications.dispatch_pending_title": "Ausstehende Benachrichtigungen zustellen",
"i18n:govoplan-notifications.dispatch_pending_message": "Versuchen, bis zu 50 geeignete Benachrichtigungen in diesem Mandanten zuzustellen. Von einem Anbieter angenommene Ergebnisse werden nicht automatisch wiederholt.",
"i18n:govoplan-notifications.selected_notification": "die ausgewählte Benachrichtigung",
"i18n:govoplan-notifications.delivery_attempts": "Zustellversuche",
"i18n:govoplan-notifications.select_notification_help": "Wählen Sie eine Benachrichtigung aus, um Zustellstatus, Quellkontext und Inhalt zu prüfen.",
"i18n:govoplan-notifications.no_message_body": "Es wurde kein Nachrichtentext angegeben.",
"i18n:govoplan-notifications.open_related_item": "Zugehöriges Element öffnen",
"i18n:govoplan-notifications.source_and_delivery": "Quelle und Zustellung",
"i18n:govoplan-notifications.source": "Quelle",
"i18n:govoplan-notifications.resource": "Ressource",
"i18n:govoplan-notifications.recipient": "Empfänger",
"i18n:govoplan-notifications.priority": "Priorität",
"i18n:govoplan-notifications.queued": "Eingereiht",
"i18n:govoplan-notifications.sent": "Gesendet",
"i18n:govoplan-notifications.read": "Gelesen",
"i18n:govoplan-notifications.attempts": "Versuche",
"i18n:govoplan-notifications.none": "Keine Angabe",
"i18n:govoplan-notifications.no_delivery_attempt": "Es wurde noch kein Zustellversuch aufgezeichnet.",
"i18n:govoplan-notifications.not_set": "Nicht gesetzt",
"i18n:govoplan-notifications.request_failed": "Anfrage fehlgeschlagen",
"i18n:govoplan-notifications.preferences_saved": "Benachrichtigungseinstellungen gespeichert.",
"i18n:govoplan-notifications.preferences_load_failed": "Benachrichtigungseinstellungen konnten nicht geladen werden.",
"i18n:govoplan-notifications.preferences_save_failed": "Benachrichtigungseinstellungen konnten nicht gespeichert werden.",
"i18n:govoplan-notifications.save_preferences": "Einstellungen speichern",
"i18n:govoplan-notifications.saving": "Speichern",
"i18n:govoplan-notifications.version_value": "Version {value0}",
"i18n:govoplan-notifications.hidden_by_view": "Durch die aktive Ansicht ausgeblendet",
"i18n:govoplan-notifications.unread_badge": "Ungelesen-Markierung",
"i18n:govoplan-notifications.unread_badge_help": "Anzahl ungelesener Benachrichtigungen in der Titelleiste anzeigen.",
"i18n:govoplan-notifications.muted_sources": "Stummgeschaltete Quellmodule",
"i18n:govoplan-notifications.muted_sources_help": "Wählen Sie aktive Module. Gespeicherte, durch die aktuelle Ansicht ausgeblendete Module bleiben hier sichtbar und werden nicht verworfen.",
"i18n:govoplan-notifications.add_source_module": "Quellmodul hinzufügen",
"i18n:govoplan-notifications.no_visible_modules": "Keine sichtbaren Module passen.",
"i18n:govoplan-notifications.delivery": "Zustellung",
"i18n:govoplan-notifications.email_notifications": "E-Mail-Benachrichtigungen",
"i18n:govoplan-notifications.email_notifications_help": "Produktive E-Mails über das optionale Mail-Modul zustellen. Benachrichtigungen in GovOPlaN funktionieren weiter, wenn Mail nicht verfügbar ist.",
"i18n:govoplan-notifications.email_digest": "E-Mail-Zusammenfassung",
"i18n:govoplan-notifications.email_digest_help": "Geeignete Benachrichtigungen zu Zusammenfassungen bündeln, wenn die Zustellregeln dies unterstützen.",
"i18n:govoplan-notifications.loading_summary": "Benachrichtigungsübersicht wird geladen",
"i18n:govoplan-notifications.unread": "Ungelesen",
"i18n:govoplan-notifications.pending": "Ausstehend",
"i18n:govoplan-notifications.failed": "Fehlgeschlagen",
"i18n:govoplan-notifications.value_total": "{value0} insgesamt",
"i18n:govoplan-notifications.awaiting_delivery": "Wartet auf Zustellung",
"i18n:govoplan-notifications.delivery_failures": "Zustellfehler",
"i18n:govoplan-notifications.open_center": "Benachrichtigungszentrale öffnen",
"i18n:govoplan-notifications.status.all": "Alle",
"i18n:govoplan-notifications.status.pending": "Ausstehend",
"i18n:govoplan-notifications.status.queued": "Eingereiht",
"i18n:govoplan-notifications.status.sending": "Wird gesendet",
"i18n:govoplan-notifications.status.accepted": "Angenommen",
"i18n:govoplan-notifications.status.paused": "Pausiert",
"i18n:govoplan-notifications.status.sent": "Gesendet",
"i18n:govoplan-notifications.status.failed": "Fehlgeschlagen",
"i18n:govoplan-notifications.status.skipped": "Übersprungen",
"i18n:govoplan-notifications.status.cancelled": "Abgebrochen",
"i18n:govoplan-notifications.status.read": "Gelesen",
"i18n:govoplan-notifications.status.acknowledged": "Bestätigt"
}
};
+19 -12
View File
@@ -18,10 +18,10 @@ const notificationDashboardWidgets: DashboardWidgetsUiCapability = {
{
id: "notifications.summary",
surfaceId: "notifications.widget.summary",
title: "Notifications",
description: "Unread notifications and delivery state.",
title: "i18n:govoplan-notifications.surface.center",
description: "i18n:govoplan-notifications.widget_description",
moduleId: "notifications",
category: "Communication",
category: "i18n:govoplan-notifications.communication",
order: 60,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
@@ -33,8 +33,8 @@ const notificationDashboardWidgets: DashboardWidgetsUiCapability = {
configurationFields: [
{
id: "showDeliveryState",
label: "Show delivery state",
description: "Include pending and failed delivery counts.",
label: "i18n:govoplan-notifications.show_delivery_state",
description: "i18n:govoplan-notifications.show_delivery_state_help",
kind: "boolean"
}
],
@@ -59,7 +59,7 @@ const notificationSettingsSections: SettingsSectionsUiCapability = {
{
id: "notifications",
surfaceId: "notifications.settings.preferences",
label: "Notifications",
label: "i18n:govoplan-notifications.surface.center",
group: "ui",
order: 40,
anyOf: notificationRead,
@@ -70,29 +70,36 @@ const notificationSettingsSections: SettingsSectionsUiCapability = {
export const notificationsModule: PlatformWebModule = {
id: "notifications",
label: "Notifications",
label: "i18n:govoplan-notifications.surface.center",
version: "1.0.0",
dependencies: [],
optionalDependencies: ["mail", "tasks", "portal", "workflow", "calendar", "scheduling"],
translations: generatedTranslations,
viewSurfaces: [
{ id: "notifications.page.inbox", moduleId: "notifications", kind: "section", label: "i18n:govoplan-notifications.surface.inbox", parentId: "notifications.route.notifications", order: 20 },
{ id: "notifications.page.detail", moduleId: "notifications", kind: "section", label: "i18n:govoplan-notifications.surface.detail", parentId: "notifications.route.notifications", order: 30 },
{ id: "notifications.page.delivery", moduleId: "notifications", kind: "section", label: "i18n:govoplan-notifications.surface.delivery", parentId: "notifications.page.detail", order: 40 },
{ id: "notifications.action.mark-read", moduleId: "notifications", kind: "action", label: "i18n:govoplan-notifications.surface.mark_read", parentId: "notifications.page.detail", order: 50 },
{ id: "notifications.action.acknowledge", moduleId: "notifications", kind: "action", label: "i18n:govoplan-notifications.surface.acknowledge", parentId: "notifications.page.detail", order: 60 },
{ id: "notifications.action.cancel", moduleId: "notifications", kind: "action", label: "i18n:govoplan-notifications.surface.cancel", parentId: "notifications.page.delivery", order: 70 },
{ id: "notifications.action.dispatch", moduleId: "notifications", kind: "action", label: "i18n:govoplan-notifications.surface.dispatch", parentId: "notifications.page.delivery", order: 80 },
{
id: "notifications.widget.summary",
moduleId: "notifications",
kind: "section",
label: "Notification summary widget",
order: 30
label: "i18n:govoplan-notifications.surface.widget_summary",
order: 90
},
{
id: "notifications.settings.preferences",
moduleId: "notifications",
kind: "section",
label: "Notification preferences",
order: 40
label: "i18n:govoplan-notifications.surface.preferences",
order: 100
}
],
routes: [
{ path: "/notifications", anyOf: notificationRead, order: 59, render: ({ settings, auth }) => createElement(NotificationCenterPage, { settings, auth }) }
{ path: "/notifications", anyOf: notificationRead, order: 59, surfaceId: "notifications.route.notifications", render: ({ settings, auth }) => createElement(NotificationCenterPage, { settings, auth }) }
],
uiCapabilities: {
"settings.sections": notificationSettingsSections,
+38
View File
@@ -15,6 +15,8 @@
.notifications-widget-actions {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
margin-top: 14px;
}
@@ -74,6 +76,11 @@
.notifications-status-filter {
width: calc(100% - 16px);
margin: 8px;
overflow-x: auto;
}
.notifications-status-filter .segmented-control-option {
flex: 0 0 auto;
}
.notifications-list {
@@ -158,6 +165,10 @@
margin: 8px 12px 0;
}
.notifications-workspace > .action-blocker-hint {
margin: 8px 12px 0;
}
.notifications-detail {
min-height: 0;
overflow: auto;
@@ -208,6 +219,16 @@
align-items: start;
}
.notifications-settings-documentation,
.notifications-settings-panel > .action-blocker-hint {
grid-column: 1 / -1;
}
.notifications-settings-documentation {
display: flex;
justify-content: flex-end;
}
.notifications-settings-inline-title {
display: flex;
align-items: center;
@@ -223,6 +244,17 @@
font-size: 15px;
}
.notifications-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.notifications-section-heading h2 {
margin: 0;
}
.notifications-properties dl {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
@@ -285,6 +317,12 @@
text-align: center;
}
.notifications-permission-state {
max-width: 860px;
margin: 0 auto;
padding: 32px 20px;
}
.notifications-empty-state h1 {
margin: 0;
color: var(--text-strong);