+ {/* Phase B: link к per-channel preferences page. Всегда visible
+ (даже если unread=0 — пользователь может зайти настроить впрок). */}
+
+ {t('notifications.preferencesLink', {
+ defaultValue: 'Настроить каналы →',
+ })}
+
+ {totalUnread > 0 && (
- )}
+ )}
+
)
diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts
index d9a2b30..f74a613 100644
--- a/ordinis-admin-ui/src/i18n.ts
+++ b/ordinis-admin-ui/src/i18n.ts
@@ -617,6 +617,25 @@ i18n
'reviews.mine.anonTitle': 'Войдите чтобы видеть свои черновики',
'reviews.mine.anonDescription':
'Собственные drafts видны только после авторизации — backend фильтрует по вашему JWT sub claim. Войдите через Keycloak чтобы открыть Мои.',
+ // === /me/notifications/preferences (Phase B) ===
+ 'prefs.title': 'Уведомления',
+ 'prefs.description':
+ 'Per-channel настройки доставки. Применяется только к вашим уведомлениям (reviewer pool inbox остаётся ON).',
+ 'prefs.anonTitle': 'Войдите чтобы управлять подписками',
+ 'prefs.anonDescription':
+ 'Настройки уведомлений хранятся per-user (по JWT sub claim). Войдите через Keycloak чтобы открыть страницу.',
+ 'prefs.email.label': 'Email',
+ 'prefs.email.description':
+ 'SMTP доставка через сконфигурированный mail.host. На staging это mailpit catcher; на prod — реальный relay.',
+ 'prefs.express.label': 'Express',
+ 'prefs.express.description':
+ 'Корпоративный мессенджер. Канал ещё не подключён — toggle сохраняется на будущее.',
+ 'prefs.telegram.label': 'Telegram',
+ 'prefs.telegram.description':
+ 'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.',
+ 'prefs.comingSoon': 'Скоро',
+ 'prefs.saveError': 'Не удалось сохранить настройки. Попробуйте ещё раз.',
+ 'notifications.preferencesLink': 'Настроить каналы →',
'reviews.mine.filter.all': 'Все',
'reviews.mine.filter.pending': 'На ревью',
'reviews.mine.filter.wip': 'В работе',
@@ -1377,6 +1396,25 @@ i18n
'reviews.mine.anonTitle': 'Sign in to see your drafts',
'reviews.mine.anonDescription':
'Your own drafts are visible only after sign-in — backend filters by your JWT sub claim. Sign in via Keycloak to open Mine.',
+ // === /me/notifications/preferences (Phase B) ===
+ 'prefs.title': 'Notifications',
+ 'prefs.description':
+ 'Per-channel delivery preferences. Applies only to your notifications (reviewer pool inbox stays ON).',
+ 'prefs.anonTitle': 'Sign in to manage your subscriptions',
+ 'prefs.anonDescription':
+ 'Notification preferences are stored per-user (by JWT sub claim). Sign in via Keycloak to open this page.',
+ 'prefs.email.label': 'Email',
+ 'prefs.email.description':
+ 'SMTP delivery via configured mail.host. On staging it is the mailpit catcher; on prod — real relay.',
+ 'prefs.express.label': 'Express',
+ 'prefs.express.description':
+ 'Corporate messenger. Channel not wired yet — toggle is stored for later.',
+ 'prefs.telegram.label': 'Telegram',
+ 'prefs.telegram.description':
+ 'Telegram bot. Channel not wired yet — toggle is stored for later.',
+ 'prefs.comingSoon': 'Soon',
+ 'prefs.saveError': 'Failed to save preferences. Please try again.',
+ 'notifications.preferencesLink': 'Manage channels →',
'reviews.mine.emptyFilter': 'No drafts in this category',
'reviews.mine.filter.all': 'All',
'reviews.mine.filter.pending': 'Under review',
diff --git a/ordinis-admin-ui/src/routeTree.gen.ts b/ordinis-admin-ui/src/routeTree.gen.ts
index 8e6c708..ffc69bd 100644
--- a/ordinis-admin-ui/src/routeTree.gen.ts
+++ b/ordinis-admin-ui/src/routeTree.gen.ts
@@ -22,6 +22,7 @@ import { Route as WebhooksIndexRouteImport } from './routes/webhooks.index'
import { Route as DictionariesIndexRouteImport } from './routes/dictionaries.index'
import { Route as WebhooksIdRouteImport } from './routes/webhooks.$id'
import { Route as DictionariesNameRouteImport } from './routes/dictionaries.$name'
+import { Route as MeNotificationsPreferencesRouteImport } from './routes/me.notifications.preferences'
const WebhooksRoute = WebhooksRouteImport.update({
id: '/webhooks',
@@ -88,6 +89,12 @@ const DictionariesNameRoute = DictionariesNameRouteImport.update({
path: '/$name',
getParentRoute: () => DictionariesRoute,
} as any)
+const MeNotificationsPreferencesRoute =
+ MeNotificationsPreferencesRouteImport.update({
+ id: '/me/notifications/preferences',
+ path: '/me/notifications/preferences',
+ getParentRoute: () => rootRouteImport,
+ } as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -103,6 +110,7 @@ export interface FileRoutesByFullPath {
'/webhooks/$id': typeof WebhooksIdRoute
'/dictionaries/': typeof DictionariesIndexRoute
'/webhooks/': typeof WebhooksIndexRoute
+ '/me/notifications/preferences': typeof MeNotificationsPreferencesRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -116,6 +124,7 @@ export interface FileRoutesByTo {
'/webhooks/$id': typeof WebhooksIdRoute
'/dictionaries': typeof DictionariesIndexRoute
'/webhooks': typeof WebhooksIndexRoute
+ '/me/notifications/preferences': typeof MeNotificationsPreferencesRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -132,6 +141,7 @@ export interface FileRoutesById {
'/webhooks/$id': typeof WebhooksIdRoute
'/dictionaries/': typeof DictionariesIndexRoute
'/webhooks/': typeof WebhooksIndexRoute
+ '/me/notifications/preferences': typeof MeNotificationsPreferencesRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -149,6 +159,7 @@ export interface FileRouteTypes {
| '/webhooks/$id'
| '/dictionaries/'
| '/webhooks/'
+ | '/me/notifications/preferences'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -162,6 +173,7 @@ export interface FileRouteTypes {
| '/webhooks/$id'
| '/dictionaries'
| '/webhooks'
+ | '/me/notifications/preferences'
id:
| '__root__'
| '/'
@@ -177,6 +189,7 @@ export interface FileRouteTypes {
| '/webhooks/$id'
| '/dictionaries/'
| '/webhooks/'
+ | '/me/notifications/preferences'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -189,6 +202,7 @@ export interface RootRouteChildren {
ReviewsRoute: typeof ReviewsRoute
SearchRoute: typeof SearchRoute
WebhooksRoute: typeof WebhooksRouteWithChildren
+ MeNotificationsPreferencesRoute: typeof MeNotificationsPreferencesRoute
}
declare module '@tanstack/react-router' {
@@ -284,6 +298,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DictionariesNameRouteImport
parentRoute: typeof DictionariesRoute
}
+ '/me/notifications/preferences': {
+ id: '/me/notifications/preferences'
+ path: '/me/notifications/preferences'
+ fullPath: '/me/notifications/preferences'
+ preLoaderRoute: typeof MeNotificationsPreferencesRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
@@ -325,6 +346,7 @@ const rootRouteChildren: RootRouteChildren = {
ReviewsRoute: ReviewsRoute,
SearchRoute: SearchRoute,
WebhooksRoute: WebhooksRouteWithChildren,
+ MeNotificationsPreferencesRoute: MeNotificationsPreferencesRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx b/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx
new file mode 100644
index 0000000..b674b60
--- /dev/null
+++ b/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx
@@ -0,0 +1,180 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { useTranslation } from 'react-i18next'
+import { useAuth } from 'react-oidc-context'
+import {
+ Button,
+ EmptyState,
+ LoadingBlock,
+ PageHeader,
+ Panel,
+ QueryErrorState,
+ Switch,
+} from '@/ui'
+import { useMyNotificationPreferences } from '@/api/queries'
+import { useUpdateNotificationPreferences } from '@/api/mutations'
+import type { NotificationPreferences } from '@/api/client'
+
+export const Route = createFileRoute('/me/notifications/preferences')({
+ component: NotificationPreferencesPage,
+})
+
+/**
+ * Phase B — UI для per-channel notification opt-in/out.
+ *
+ *
Email — единственный actually wired channel в Phase A/B (через Spring
+ * \`spring.mail.host\`). Express + Telegram toggles disabled с подсказкой
+ * "Скоро" — backend хранит preference (на случай early opt-in), но dispatcher
+ * никогда не пошлёт пока channel bean не зарегистрирован.
+ *
+ *