From 69f1a05a1f7da4904bdada8c2ff73c247ea763aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Sun, 10 May 2026 19:34:47 +0000 Subject: [PATCH] =?UTF-8?q?fix(admin-ui):=20v1.1.1=20patch=20=E2=80=94=20v?= =?UTF-8?q?ersion=20routing=20+=20guest=20mode=20+=20timeline=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ordinis-admin-ui/nginx.conf | 15 ++ ordinis-admin-ui/src/auth/RequireAuth.tsx | 76 +++---- ordinis-admin-ui/src/auth/TokenSync.tsx | 10 +- ordinis-admin-ui/src/auth/useCanMutate.ts | 19 ++ .../timetravel/TimeTravelPicker.tsx | 212 ++++++++++++++++++ ordinis-admin-ui/src/i18n.ts | 30 ++- .../src/routes/dictionaries.$name.tsx | 159 ++++++------- .../src/routes/dictionaries.index.tsx | 42 +++- 8 files changed, 419 insertions(+), 144 deletions(-) create mode 100644 ordinis-admin-ui/src/auth/useCanMutate.ts create mode 100644 ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx diff --git a/ordinis-admin-ui/nginx.conf b/ordinis-admin-ui/nginx.conf index cb6d11a..168eba5 100644 --- a/ordinis-admin-ui/nginx.conf +++ b/ordinis-admin-ui/nginx.conf @@ -29,6 +29,21 @@ server { proxy_pass_request_headers on; } + # /api/v1/version — VersionController в ordinis-rest-api → подключён ТОЛЬКО в + # writer (см. ordinis-app/pom.xml). Без явного правила generic /api/v1/ ниже + # роутит GET → read-api → 500 NoResourceFoundException → пустой traceId. + # Тот же класс баги что для /drafts/me ниже. + location = /api/v1/version { + set $backend "ordinis-app.${ORDINIS_NAMESPACE}.svc.${CLUSTER_DOMAIN}"; + proxy_pass http://$backend$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass_request_headers on; + } + # /api/v1/admin/* — audit + outbox admin endpoints на writer. location ~ ^/api/v1/admin/ { set $backend "ordinis-app.${ORDINIS_NAMESPACE}.svc.${CLUSTER_DOMAIN}"; diff --git a/ordinis-admin-ui/src/auth/RequireAuth.tsx b/ordinis-admin-ui/src/auth/RequireAuth.tsx index 779ebf5..a62c76f 100644 --- a/ordinis-admin-ui/src/auth/RequireAuth.tsx +++ b/ordinis-admin-ui/src/auth/RequireAuth.tsx @@ -1,19 +1,27 @@ -import { useEffect, type ReactNode } from 'react' +import { type ReactNode } from 'react' import { useAuth } from 'react-oidc-context' /** - * Гейтит всё содержимое приложения по аутентификации. Если юзер не залогинен — - * сразу редиректим на Keycloak (без промежуточной страницы "Вы не авторизованы" - * и без красного 401 от axios). Пока auth state ещё определяется — показываем - * нейтральный spinner. + * Auth gate с гостевым режимом view-only. * - * Сценарии: - * - First visit, нет токена → signinRedirect → Keycloak login → redirect_uri → - * onSigninCallback чистит URL → дети рендерятся. - * - Истёк access_token, silent renew не успел → 401 от backend ловится в - * {@link TokenSync} interceptor, который дёргает signinSilent/signinRedirect. - * - Возврат с ошибкой OIDC (?error=access_denied) → не редиректим в цикле, - * показываем сообщение. + *

До v1.1.1 этот компонент force-redirect'ил неаутентифицированных юзеров + * на Keycloak. Теперь — guest-friendly: anonymous user видит UI с PUBLIC scope + * данными (backend отдаёт PUBLIC dictionaries без auth, см. SecurityConfig + * permissive mode + ScopeContext anonymous → PUBLIC). + * + *

Логин теперь пользовательское действие: клик на кнопку «Войти» в top bar + * → {@link useAuth().signinRedirect()}. После возврата из Keycloak пользователь + * получает scope по ролям JWT и видит INTERNAL/RESTRICTED данные. + * + *

Сценарии: + *

*/ export function RequireAuth({ children }: { children: ReactNode }) { const auth = useAuth() @@ -22,27 +30,6 @@ export function RequireAuth({ children }: { children: ReactNode }) { typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('error') - useEffect(() => { - if (oidcError) return - if (auth.isLoading || auth.activeNavigator) return - if (auth.isAuthenticated) return - // Critical guard: auth.error → signinRedirect уже упал (Keycloak недоступен, - // OIDC discovery 404 etc). Без этого guard'а useEffect зациклится: каждый - // failed redirect ставит auth.error → re-render → guard не срабатывает → - // снова signinRedirect. С guard'ом UI показывает "Не удалось подключиться" - // блок ниже (использует auth.error.message). - if (auth.error) return - auth.signinRedirect().catch((err) => { - console.error('[auth] signinRedirect failed', err) - }) - }, [ - auth.isAuthenticated, - auth.isLoading, - auth.activeNavigator, - auth.error, - oidcError, - ]) - if (oidcError) { return (
@@ -66,24 +53,33 @@ export function RequireAuth({ children }: { children: ReactNode }) { ) } + // Keycloak unreachable / OIDC discovery failed — показываем ошибку, но НЕ + // блокируем UI (guest mode всё равно работает на read-api без JWT). Юзер + // может видеть PUBLIC данные пока Keycloak вернётся в строй. if (auth.error && !auth.isAuthenticated) { return ( -
-
-

Не удалось подключиться к авторизации

-

{auth.error.message}

+ <> +
+ Авторизация недоступна: {auth.error.message}. Доступен просмотр публичных данных.
-
+ {children} + ) } - if (!auth.isAuthenticated) { + // Loading — initial OIDC bootstrap (silent SSO check). Показываем нейтральный + // spinner, чтобы избежать flash of unauthenticated UI если silent SSO успеет + // вернуть active session. + if (auth.isLoading) { return (
-
Перенаправление на вход…
+
Загрузка…
) } + // Anonymous OR authenticated — рендерим children всегда. UI в дочерних + // компонентах сам адаптируется (через useAuth().isAuthenticated): + // hide mutation buttons, show «Войти» CTA, тонировать blocked actions. return <>{children} } diff --git a/ordinis-admin-ui/src/auth/TokenSync.tsx b/ordinis-admin-ui/src/auth/TokenSync.tsx index b577df4..0863936 100644 --- a/ordinis-admin-ui/src/auth/TokenSync.tsx +++ b/ordinis-admin-ui/src/auth/TokenSync.tsx @@ -54,6 +54,8 @@ export function TokenSync() { redirecting.current = false } if (auth.isAuthenticated) { + // Authenticated user hit 401 → token истёк. Silent renew, иначе full + // redirect для re-login. auth .signinSilent() .then(reset) @@ -64,7 +66,13 @@ export function TokenSync() { auth.signinRedirect().catch(reset) }) } else { - auth.signinRedirect().catch(reset) + // Anonymous user попытался hit auth-required endpoint (mutation, + // INTERNAL/RESTRICTED scope dictionary). Не редиректим автоматически — + // UI должен сам показать «Войдите для редактирования» CTA, а юзер + // сознательно кликнет «Войти». Авто-redirect ломает guest mode: + // случайный 401 от любого API (например INTERNAL fetch) выкинул бы + // в Keycloak без явного намерения. + reset() } }) return () => setUnauthorizedHandler(null) diff --git a/ordinis-admin-ui/src/auth/useCanMutate.ts b/ordinis-admin-ui/src/auth/useCanMutate.ts new file mode 100644 index 0000000..43cfa21 --- /dev/null +++ b/ordinis-admin-ui/src/auth/useCanMutate.ts @@ -0,0 +1,19 @@ +import { useAuth } from 'react-oidc-context' + +/** + * Может ли текущий пользователь выполнять mutation операции (create / update / + * delete / close / cascade). + * + *

Гость (anonymous) видит UI в read-only режиме: кнопки «Создать», «Удалить», + * иконки edit'а скрыты, schema editor disabled. Backend всё равно отрежет 401 + * на mutation endpoint'ах (writer требует JWT для POST/PUT/PATCH/DELETE), но + * скрытие UI избегает confusing user experience «жмёшь кнопку → toast 401». + * + *

Использовать в компонентах вместо прямого {@code useAuth().isAuthenticated}, + * чтобы при будущей более тонкой role-based gate'е (например, разрешить + * INTERNAL юзерам только PUBLIC mutations) — поменять одно место. + */ +export function useCanMutate(): boolean { + const auth = useAuth() + return auth.isAuthenticated +} diff --git a/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx b/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx new file mode 100644 index 0000000..f5036cd --- /dev/null +++ b/ordinis-admin-ui/src/components/timetravel/TimeTravelPicker.tsx @@ -0,0 +1,212 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { DatePicker } from '@nstart/ui' +import { ClockCounterClockwiseIcon, XIcon } from '@phosphor-icons/react' + +/** + * Picker для time-travel: «активная версия на момент X». + * + *

До v1.1.1 был голый {@code } — pre-2010 + * native control с непредсказуемым стилем по браузерам и без TZ awareness. + * Здесь: + *

    + *
  • {@code DatePicker} из {@code @nstart/ui} (RU locale, beautiful native dropdown).
  • + *
  • Time entry — двух-полевой HH:MM (number inputs).
  • + *
  • Quick-preset chips: «Сейчас» / «−1ч» / «Вчера» / «Неделя» / «Месяц» / «Год».
  • + *
  • Relative-time бейдж рядом со значением (через {@code Intl.RelativeTimeFormat}).
  • + *
+ * + *

State модель: контролируемый компонент, parent держит {@code at: ISO string | + * undefined}. Undefined = «сейчас» (live). Любое preset / picker change → onChange + * с новым ISO. Сброс через {@code onClear} (parent очищает {@code ?at} в URL). + */ +export function TimeTravelPicker({ + value, + onChange, + onClear, + onClose, +}: { + value: string | undefined + onChange: (iso: string) => void + onClear: () => void + onClose: () => void +}) { + const { t, i18n } = useTranslation() + const lang = (i18n.language || 'ru').split('-')[0] + + // Текущее значение date-only (для DatePicker) и time HH:MM (для number inputs). + const valueDate = useMemo(() => { + if (!value) return undefined + const d = new Date(value) + return Number.isNaN(d.getTime()) ? undefined : d + }, [value]) + + const hh = valueDate ? valueDate.getHours() : new Date().getHours() + const mm = valueDate ? valueDate.getMinutes() : new Date().getMinutes() + + // Combine date + HH:MM в ISO с локальным TZ-offset (preserve wall-clock). + const emitWithTime = (date: Date, hours: number, minutes: number) => { + const d = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + hours, + minutes, + 0, + 0, + ) + onChange(d.toISOString()) + } + + const handleDateChange = (d: Date | null) => { + if (!d) { + onClear() + return + } + emitWithTime(d, hh, mm) + } + + const handleHoursChange = (h: number) => { + if (!valueDate) { + // First time-edit без выбранной даты → принимаем «сегодня». + emitWithTime(new Date(), Math.max(0, Math.min(23, h)), mm) + return + } + emitWithTime(valueDate, Math.max(0, Math.min(23, h)), mm) + } + + const handleMinutesChange = (m: number) => { + if (!valueDate) { + emitWithTime(new Date(), hh, Math.max(0, Math.min(59, m))) + return + } + emitWithTime(valueDate, hh, Math.max(0, Math.min(59, m))) + } + + const applyPreset = (deltaMs: number) => { + const target = new Date(Date.now() + deltaMs) + onChange(target.toISOString()) + } + + // Relative-time строка: «3 ч назад», «через 2 дня». Intl.RelativeTimeFormat + // нативный, без depends, locale-aware. + const relativeLabel = useMemo(() => { + if (!valueDate) return null + const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' }) + const diffMs = valueDate.getTime() - Date.now() + const absH = Math.abs(diffMs) / 3_600_000 + const absD = absH / 24 + if (absH < 1) { + return rtf.format(Math.round(diffMs / 60_000), 'minute') + } + if (absH < 48) { + return rtf.format(Math.round(diffMs / 3_600_000), 'hour') + } + if (absD < 60) { + return rtf.format(Math.round(diffMs / 86_400_000), 'day') + } + return rtf.format(Math.round(diffMs / (86_400_000 * 30)), 'month') + }, [valueDate, lang]) + + const presets: ReadonlyArray<{ key: string; label: string; deltaMs: number }> = [ + { key: 'now', label: t('timeTravel.preset.now'), deltaMs: 0 }, + { key: '-1h', label: t('timeTravel.preset.hourAgo'), deltaMs: -3_600_000 }, + { key: '-1d', label: t('timeTravel.preset.dayAgo'), deltaMs: -86_400_000 }, + { key: '-7d', label: t('timeTravel.preset.weekAgo'), deltaMs: -7 * 86_400_000 }, + { key: '-30d', label: t('timeTravel.preset.monthAgo'), deltaMs: -30 * 86_400_000 }, + { key: '-1y', label: t('timeTravel.preset.yearAgo'), deltaMs: -365 * 86_400_000 }, + ] + + return ( +

+
+ + + {t('timeTravel.label')} + + +
+ +
+ +
+ + {t('timeTravel.time')} + +
+ handleHoursChange(parseInt(e.target.value, 10) || 0)} + className="w-12 px-2 py-1 rounded-sm border border-regolith bg-white text-sm font-mono text-center" + aria-label={t('timeTravel.hours')} + /> + : + handleMinutesChange(parseInt(e.target.value, 10) || 0)} + className="w-12 px-2 py-1 rounded-sm border border-regolith bg-white text-sm font-mono text-center" + aria-label={t('timeTravel.minutes')} + /> +
+
+
+ +
+ + {t('timeTravel.quickPresets')} + + {presets.map((p) => ( + + ))} +
+ + {valueDate && ( +
+ + + {valueDate.toLocaleString(lang === 'en' ? 'en-US' : 'ru-RU', { + dateStyle: 'long', + timeStyle: 'short', + })} + + {relativeLabel && ( + + ({relativeLabel}) + + )} + +
+ )} +
+ ) +} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index e9a86e7..9b1b3a5 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -386,10 +386,21 @@ i18n 'lineage.refs.staleHint': 'Данные из materialized view; обновляются раз в минуту. Закрытые между обновлениями записи отфильтрованы по valid_to.', // === Time-travel (CEO plan v1 stretch) === 'timeTravel.button': 'Просмотр на дату', - 'timeTravel.label': 'Активная версия на момент:', + 'timeTravel.label': 'Активная версия на момент', 'timeTravel.viewing': 'Просмотр на момент: {{date}}', - 'timeTravel.clear': 'Сейчас', + 'timeTravel.clear': 'Сбросить', 'timeTravel.close': 'Закрыть', + 'timeTravel.date': 'Дата', + 'timeTravel.time': 'Время', + 'timeTravel.hours': 'Часы', + 'timeTravel.minutes': 'Минуты', + 'timeTravel.quickPresets': 'Быстро:', + 'timeTravel.preset.now': 'Сейчас', + 'timeTravel.preset.hourAgo': '−1 час', + 'timeTravel.preset.dayAgo': 'Вчера', + 'timeTravel.preset.weekAgo': '−7 дней', + 'timeTravel.preset.monthAgo': '−30 дней', + 'timeTravel.preset.yearAgo': '−1 год', // === Cascade close (Phase 3) === 'cascade.title': 'Каскадное закрытие', 'cascade.summary.willClose_one': 'Будет закрыта {{total}} запись (включая «{{target}}»). Подтверждение требуется.', @@ -850,10 +861,21 @@ i18n 'lineage.refs.staleHint': 'Data from materialized view, refreshed once per minute. Records closed between refreshes are filtered by valid_to.', // === Time-travel (CEO plan v1 stretch) === 'timeTravel.button': 'View as of date', - 'timeTravel.label': 'Active version as of:', + 'timeTravel.label': 'Active version as of', 'timeTravel.viewing': 'Viewing as of: {{date}}', - 'timeTravel.clear': 'Now', + 'timeTravel.clear': 'Reset', 'timeTravel.close': 'Close', + 'timeTravel.date': 'Date', + 'timeTravel.time': 'Time', + 'timeTravel.hours': 'Hours', + 'timeTravel.minutes': 'Minutes', + 'timeTravel.quickPresets': 'Quick:', + 'timeTravel.preset.now': 'Now', + 'timeTravel.preset.hourAgo': '−1 hour', + 'timeTravel.preset.dayAgo': 'Yesterday', + 'timeTravel.preset.weekAgo': '−7 days', + 'timeTravel.preset.monthAgo': '−30 days', + 'timeTravel.preset.yearAgo': '−1 year', // === Cascade close (Phase 3) === 'cascade.title': 'Cascade close', 'cascade.summary.willClose_one': '{{total}} record will be closed (including «{{target}}»). Confirmation required.', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 102d66e..00aa2d0 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -31,6 +31,7 @@ import { type RecordsFilter, } from '@/api/queries' import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey } from '@/api/mutations' +import { useCanMutate } from '@/auth/useCanMutate' import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client' import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' @@ -39,7 +40,8 @@ import { DictionaryHubView } from '@/components/lineage/DictionaryHubView' import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog' import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog' -import { nowIsoLocal, toDateTimeLocalInput, fromDateTimeLocalInput } from '@/lib/dates' +import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker' +import { nowIsoLocal } from '@/lib/dates' import { recordDisplayName } from '@/lib/locales' import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style' @@ -103,6 +105,7 @@ function DictionaryDetail() { const navigate = useNavigate({ from: Route.fullPath }) const { t } = useTranslation() const detailQuery = useDictionaryDetail(name) + const canMutate = useCanMutate() // AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт // только в local state (не сериализуем в URL — слишком длинно). @@ -523,24 +526,28 @@ function DictionaryDetail() { > {t('timeTravel.button')} - - + {canMutate && ( + <> + + + + )}
} /> @@ -612,50 +619,12 @@ function DictionaryDetail() { ISO datetime в URL ?at=… — share-friendly. Active state показывает ambient banner справа от input'а. Phase v1 stretch. */} {timeTravelOpen && ( -
- - {t('timeTravel.label')} - - { - if (!e.target.value) { - setTimeTravelAt(undefined) - return - } - setTimeTravelAt(fromDateTimeLocalInput(e.target.value)) - }} - aria-label={t('timeTravel.label')} - /> - {timeTravelAt && ( - <> - - {new Date(timeTravelAt).toLocaleString()} - - - - )} - -
+ setTimeTravelAt(iso)} + onClear={() => setTimeTravelAt(undefined)} + onClose={() => setTimeTravelOpen(false)} + /> )} {/* Active state — ambient banner если ?at= задан, даже если picker закрыт. */} @@ -745,7 +714,7 @@ function DictionaryDetail() { ) : ( <> - {selection.size > 0 && ( + {canMutate && selection.size > 0 && ( - - - + {canMutate && ( + + + + )} {t('dict.col.businessKey')} name / code {extraColumns.map((c) => ( @@ -780,13 +751,15 @@ function DictionaryDetail() { {visibleRecords.map((r) => ( - - toggleSelect(r.businessKey)} - /> - + {canMutate && ( + + toggleSelect(r.businessKey)} + /> + + )}
{r.businessKey} @@ -844,18 +817,22 @@ function DictionaryDetail() { icon={} onClick={() => setHistoryKey(r.businessKey)} /> - } - onClick={() => setEdit({ kind: 'edit', record: r })} - /> - } - onClick={() => setEdit({ kind: 'close-confirm', record: r })} - /> + {canMutate && ( + <> + } + onClick={() => setEdit({ kind: 'edit', record: r })} + /> + } + onClick={() => setEdit({ kind: 'close-confirm', record: r })} + /> + + )}
diff --git a/ordinis-admin-ui/src/routes/dictionaries.index.tsx b/ordinis-admin-ui/src/routes/dictionaries.index.tsx index 7e4de2a..cdfcdc9 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.index.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.index.tsx @@ -18,9 +18,11 @@ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@nstart/ui' import { PlusIcon } from '@phosphor-icons/react' -import { useDictionaries, useDictionaryDependents } from '@/api/queries' +import { useQueries } from '@tanstack/react-query' +import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents } from '@/api/queries' import type { DataScope, DictionaryDefinition, SchemaDependent } from '@/api/client' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' +import { useCanMutate } from '@/auth/useCanMutate' import { SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style' // ===== Search params ===== @@ -101,6 +103,7 @@ function DictionariesPage() { const search = Route.useSearch() const { data, isLoading, error } = useDictionaries() const [createOpen, setCreateOpen] = useState(false) + const canMutate = useCanMutate() const q = (search.q ?? '').trim().toLowerCase() const deferredQuery = useDeferredValue(q) @@ -130,19 +133,40 @@ function DictionariesPage() { const setBundle = (bundle: string | undefined) => setSearch({ bundle }) + // Батчевая загрузка refBy для всех словарей — для активации withDeps filter. + // useQueries dedup'ит c per-card useDictionaryDependents (тот же queryKey), + // так что N=37 запросов выполнятся один раз и cached. Запускается ТОЛЬКО + // когда deps filter активен — для anonymous browsing N+1 burst не нужен. + const dependentsResults = useQueries({ + queries: withDepsOnly && data + ? data.map((d) => ({ ...dictionaryDependentsQuery(d.name) })) + : [], + }) + const dependentsMap = useMemo(() => { + const m = new Map() + if (!withDepsOnly || !data) return m + data.forEach((d, i) => { + const r = dependentsResults[i] + // Пока loading или error — считаем 0 (не показываем). После загрузки — + // refBy.length. Уникализация не нужна для бинарного hasDeps теста. + m.set(d.name, r?.data?.length ?? 0) + }) + return m + }, [data, dependentsResults, withDepsOnly]) + const filtered = useMemo(() => { if (!data) return [] return data.filter((d) => { if (scopeFilter.size > 0 && !scopeFilter.has(d.scope)) return false if (bundleFilter && d.bundle !== bundleFilter) return false if (!matchesQuery(d, deferredQuery)) return false - // withDepsOnly: пока нет FK metadata в DictionaryDefinition, фильтр - // эффективно no-op. После backend batch FK endpoint — заработает. - // TODO: backend пишет fk + refBy в DictionaryDefinition; раскомментить: - // if (withDepsOnly && d.fk.length === 0 && d.refBy.length === 0) return false + // «Со связями» — справочник используется в других справочниках (refBy > 0). + // Outgoing FK потребовал бы загрузки полной schema per dict — not worth + // the bandwidth для catalog filter. refBy достаточно для semantic. + if (withDepsOnly && (dependentsMap.get(d.name) ?? 0) === 0) return false return true }) - }, [data, deferredQuery, scopeFilter, bundleFilter]) + }, [data, deferredQuery, scopeFilter, bundleFilter, withDepsOnly, dependentsMap]) const bundleCounts = useMemo(() => { const out = new Map() @@ -160,7 +184,9 @@ function DictionariesPage() { const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly const resetFilters = () => navigate({ search: {} }) - const createButton = ( + // Guest mode (anonymous) — кнопка «Создать справочник» скрыта. Backend всё + // равно вернёт 401 на POST, но UI hide избегает confusing UX. + const createButton = canMutate ? ( - ) + ) : null if (isLoading) return if (error) {