Merge branch 'fix/v1.1.1-patch-bundle' into 'main'
fix(admin-ui): v1.1.1 patch — version routing + guest mode + timeline UX See merge request 2-6/2-6-4/terravault/ordinis!24
This commit is contained in:
@@ -29,6 +29,21 @@ server {
|
|||||||
proxy_pass_request_headers on;
|
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.
|
# /api/v1/admin/* — audit + outbox admin endpoints на writer.
|
||||||
location ~ ^/api/v1/admin/ {
|
location ~ ^/api/v1/admin/ {
|
||||||
set $backend "ordinis-app.${ORDINIS_NAMESPACE}.svc.${CLUSTER_DOMAIN}";
|
set $backend "ordinis-app.${ORDINIS_NAMESPACE}.svc.${CLUSTER_DOMAIN}";
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import { useEffect, type ReactNode } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
import { useAuth } from 'react-oidc-context'
|
import { useAuth } from 'react-oidc-context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Гейтит всё содержимое приложения по аутентификации. Если юзер не залогинен —
|
* Auth gate с гостевым режимом view-only.
|
||||||
* сразу редиректим на Keycloak (без промежуточной страницы "Вы не авторизованы"
|
|
||||||
* и без красного 401 от axios). Пока auth state ещё определяется — показываем
|
|
||||||
* нейтральный spinner.
|
|
||||||
*
|
*
|
||||||
* Сценарии:
|
* <p>До v1.1.1 этот компонент force-redirect'ил неаутентифицированных юзеров
|
||||||
* - First visit, нет токена → signinRedirect → Keycloak login → redirect_uri →
|
* на Keycloak. Теперь — guest-friendly: anonymous user видит UI с PUBLIC scope
|
||||||
* onSigninCallback чистит URL → дети рендерятся.
|
* данными (backend отдаёт PUBLIC dictionaries без auth, см. SecurityConfig
|
||||||
* - Истёк access_token, silent renew не успел → 401 от backend ловится в
|
* permissive mode + ScopeContext anonymous → PUBLIC).
|
||||||
* {@link TokenSync} interceptor, который дёргает signinSilent/signinRedirect.
|
*
|
||||||
* - Возврат с ошибкой OIDC (?error=access_denied) → не редиректим в цикле,
|
* <p>Логин теперь пользовательское действие: клик на кнопку «Войти» в top bar
|
||||||
* показываем сообщение.
|
* → {@link useAuth().signinRedirect()}. После возврата из Keycloak пользователь
|
||||||
|
* получает scope по ролям JWT и видит INTERNAL/RESTRICTED данные.
|
||||||
|
*
|
||||||
|
* <p>Сценарии:
|
||||||
|
* <ul>
|
||||||
|
* <li>First visit, нет токена → render children, top bar показывает «Войти».</li>
|
||||||
|
* <li>Click «Войти» → signinRedirect → Keycloak → callback → authenticated.</li>
|
||||||
|
* <li>OIDC error в URL (?error=...) → error screen (не редиректим в цикле).</li>
|
||||||
|
* <li>auth.error (Keycloak недоступен) → error screen с сообщением.</li>
|
||||||
|
* <li>Mutations пытаются 401 → TokenSync ловит, делает silentRenew или
|
||||||
|
* показывает inline ошибку (anonymous user → не редиректим без CTA).</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
@@ -22,27 +30,6 @@ export function RequireAuth({ children }: { children: ReactNode }) {
|
|||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
new URLSearchParams(window.location.search).get('error')
|
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) {
|
if (oidcError) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-8">
|
<div className="min-h-screen flex items-center justify-center p-8">
|
||||||
@@ -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) {
|
if (auth.error && !auth.isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-8">
|
<>
|
||||||
<div className="max-w-md text-center space-y-2">
|
<div className="px-4 py-2 bg-amber-50 border-b border-amber-200 text-xs text-amber-900 text-center">
|
||||||
<h1 className="text-lg font-primary">Не удалось подключиться к авторизации</h1>
|
Авторизация недоступна: {auth.error.message}. Доступен просмотр публичных данных.
|
||||||
<p className="text-sm text-carbon/70">{auth.error.message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth.isAuthenticated) {
|
// Loading — initial OIDC bootstrap (silent SSO check). Показываем нейтральный
|
||||||
|
// spinner, чтобы избежать flash of unauthenticated UI если silent SSO успеет
|
||||||
|
// вернуть active session.
|
||||||
|
if (auth.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-sm text-carbon/60 font-secondary">Перенаправление на вход…</div>
|
<div className="text-sm text-carbon/60 font-secondary">Загрузка…</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anonymous OR authenticated — рендерим children всегда. UI в дочерних
|
||||||
|
// компонентах сам адаптируется (через useAuth().isAuthenticated):
|
||||||
|
// hide mutation buttons, show «Войти» CTA, тонировать blocked actions.
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export function TokenSync() {
|
|||||||
redirecting.current = false
|
redirecting.current = false
|
||||||
}
|
}
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
|
// Authenticated user hit 401 → token истёк. Silent renew, иначе full
|
||||||
|
// redirect для re-login.
|
||||||
auth
|
auth
|
||||||
.signinSilent()
|
.signinSilent()
|
||||||
.then(reset)
|
.then(reset)
|
||||||
@@ -64,7 +66,13 @@ export function TokenSync() {
|
|||||||
auth.signinRedirect().catch(reset)
|
auth.signinRedirect().catch(reset)
|
||||||
})
|
})
|
||||||
} else {
|
} 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)
|
return () => setUnauthorizedHandler(null)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useAuth } from 'react-oidc-context'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Может ли текущий пользователь выполнять mutation операции (create / update /
|
||||||
|
* delete / close / cascade).
|
||||||
|
*
|
||||||
|
* <p>Гость (anonymous) видит UI в read-only режиме: кнопки «Создать», «Удалить»,
|
||||||
|
* иконки edit'а скрыты, schema editor disabled. Backend всё равно отрежет 401
|
||||||
|
* на mutation endpoint'ах (writer требует JWT для POST/PUT/PATCH/DELETE), но
|
||||||
|
* скрытие UI избегает confusing user experience «жмёшь кнопку → toast 401».
|
||||||
|
*
|
||||||
|
* <p>Использовать в компонентах вместо прямого {@code useAuth().isAuthenticated},
|
||||||
|
* чтобы при будущей более тонкой role-based gate'е (например, разрешить
|
||||||
|
* INTERNAL юзерам только PUBLIC mutations) — поменять одно место.
|
||||||
|
*/
|
||||||
|
export function useCanMutate(): boolean {
|
||||||
|
const auth = useAuth()
|
||||||
|
return auth.isAuthenticated
|
||||||
|
}
|
||||||
@@ -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».
|
||||||
|
*
|
||||||
|
* <p>До v1.1.1 был голый {@code <input type="datetime-local">} — pre-2010
|
||||||
|
* native control с непредсказуемым стилем по браузерам и без TZ awareness.
|
||||||
|
* Здесь:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code DatePicker} из {@code @nstart/ui} (RU locale, beautiful native dropdown).</li>
|
||||||
|
* <li>Time entry — двух-полевой HH:MM (number inputs).</li>
|
||||||
|
* <li>Quick-preset chips: «Сейчас» / «−1ч» / «Вчера» / «Неделя» / «Месяц» / «Год».</li>
|
||||||
|
* <li>Relative-time бейдж рядом со значением (через {@code Intl.RelativeTimeFormat}).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>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 (
|
||||||
|
<div className="flex flex-col gap-3 px-4 py-3 rounded-md border border-orbit/40 bg-orbit/4 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClockCounterClockwiseIcon weight="bold" size={16} className="text-orbit" />
|
||||||
|
<span className="text-2xs font-secondary uppercase tracking-label text-carbon/70">
|
||||||
|
{t('timeTravel.label')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-auto text-2xs text-carbon/60 hover:text-carbon hover:underline inline-flex items-center gap-1"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<XIcon weight="bold" size={12} />
|
||||||
|
{t('timeTravel.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<DatePicker
|
||||||
|
label={t('timeTravel.date')}
|
||||||
|
value={valueDate}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-2xs uppercase tracking-label text-carbon/70 font-secondary">
|
||||||
|
{t('timeTravel.time')}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={23}
|
||||||
|
value={String(hh).padStart(2, '0')}
|
||||||
|
onChange={(e) => 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')}
|
||||||
|
/>
|
||||||
|
<span className="text-carbon/50 font-mono">:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={59}
|
||||||
|
value={String(mm).padStart(2, '0')}
|
||||||
|
onChange={(e) => 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')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-2xs uppercase tracking-label text-carbon/60 font-secondary mr-1">
|
||||||
|
{t('timeTravel.quickPresets')}
|
||||||
|
</span>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(p.deltaMs)}
|
||||||
|
className="px-2.5 py-1 rounded-full border border-regolith hover:border-ultramarain hover:bg-ultramarain/4 text-2xs font-mono uppercase tracking-label text-carbon transition-colors"
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{valueDate && (
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-orbit/20">
|
||||||
|
<ClockCounterClockwiseIcon weight="regular" size={14} className="text-orbit shrink-0" />
|
||||||
|
<span className="text-xs font-mono text-carbon/80">
|
||||||
|
{valueDate.toLocaleString(lang === 'en' ? 'en-US' : 'ru-RU', {
|
||||||
|
dateStyle: 'long',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{relativeLabel && (
|
||||||
|
<span className="text-2xs text-carbon/60 font-secondary lowercase">
|
||||||
|
({relativeLabel})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-auto text-2xs text-ultramarain hover:underline"
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
{t('timeTravel.clear')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -386,10 +386,21 @@ i18n
|
|||||||
'lineage.refs.staleHint': 'Данные из materialized view; обновляются раз в минуту. Закрытые между обновлениями записи отфильтрованы по valid_to.',
|
'lineage.refs.staleHint': 'Данные из materialized view; обновляются раз в минуту. Закрытые между обновлениями записи отфильтрованы по valid_to.',
|
||||||
// === Time-travel (CEO plan v1 stretch) ===
|
// === Time-travel (CEO plan v1 stretch) ===
|
||||||
'timeTravel.button': 'Просмотр на дату',
|
'timeTravel.button': 'Просмотр на дату',
|
||||||
'timeTravel.label': 'Активная версия на момент:',
|
'timeTravel.label': 'Активная версия на момент',
|
||||||
'timeTravel.viewing': 'Просмотр на момент: {{date}}',
|
'timeTravel.viewing': 'Просмотр на момент: {{date}}',
|
||||||
'timeTravel.clear': 'Сейчас',
|
'timeTravel.clear': 'Сбросить',
|
||||||
'timeTravel.close': 'Закрыть',
|
'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 close (Phase 3) ===
|
||||||
'cascade.title': 'Каскадное закрытие',
|
'cascade.title': 'Каскадное закрытие',
|
||||||
'cascade.summary.willClose_one': 'Будет закрыта {{total}} запись (включая «{{target}}»). Подтверждение требуется.',
|
'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.',
|
'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) ===
|
// === Time-travel (CEO plan v1 stretch) ===
|
||||||
'timeTravel.button': 'View as of date',
|
'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.viewing': 'Viewing as of: {{date}}',
|
||||||
'timeTravel.clear': 'Now',
|
'timeTravel.clear': 'Reset',
|
||||||
'timeTravel.close': 'Close',
|
'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 close (Phase 3) ===
|
||||||
'cascade.title': 'Cascade close',
|
'cascade.title': 'Cascade close',
|
||||||
'cascade.summary.willClose_one': '{{total}} record will be closed (including «{{target}}»). Confirmation required.',
|
'cascade.summary.willClose_one': '{{total}} record will be closed (including «{{target}}»). Confirmation required.',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
type RecordsFilter,
|
type RecordsFilter,
|
||||||
} from '@/api/queries'
|
} from '@/api/queries'
|
||||||
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey } from '@/api/mutations'
|
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 type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
||||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||||
@@ -39,7 +40,8 @@ import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
|
|||||||
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
|
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
|
||||||
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
||||||
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
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 { recordDisplayName } from '@/lib/locales'
|
||||||
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
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 navigate = useNavigate({ from: Route.fullPath })
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const detailQuery = useDictionaryDetail(name)
|
const detailQuery = useDictionaryDetail(name)
|
||||||
|
const canMutate = useCanMutate()
|
||||||
|
|
||||||
// AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт
|
// AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт
|
||||||
// только в local state (не сериализуем в URL — слишком длинно).
|
// только в local state (не сериализуем в URL — слишком длинно).
|
||||||
@@ -523,6 +526,8 @@ function DictionaryDetail() {
|
|||||||
>
|
>
|
||||||
{t('timeTravel.button')}
|
{t('timeTravel.button')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{canMutate && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
leftIcon={<GearIcon weight="bold" size={16} />}
|
leftIcon={<GearIcon weight="bold" size={16} />}
|
||||||
@@ -541,6 +546,8 @@ function DictionaryDetail() {
|
|||||||
>
|
>
|
||||||
{t('dict.action.create')}
|
{t('dict.action.create')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -612,50 +619,12 @@ function DictionaryDetail() {
|
|||||||
ISO datetime в URL ?at=… — share-friendly. Active state показывает
|
ISO datetime в URL ?at=… — share-friendly. Active state показывает
|
||||||
ambient banner справа от input'а. Phase v1 stretch. */}
|
ambient banner справа от input'а. Phase v1 stretch. */}
|
||||||
{timeTravelOpen && (
|
{timeTravelOpen && (
|
||||||
<div className="flex flex-wrap items-center gap-3 px-3 py-2 rounded-sm border border-orbit/40 bg-orbit/8 text-sm">
|
<TimeTravelPicker
|
||||||
<span className="text-xs text-carbon/70 font-secondary uppercase tracking-label">
|
value={timeTravelAt}
|
||||||
{t('timeTravel.label')}
|
onChange={(iso) => setTimeTravelAt(iso)}
|
||||||
</span>
|
onClear={() => setTimeTravelAt(undefined)}
|
||||||
<input
|
onClose={() => setTimeTravelOpen(false)}
|
||||||
type="datetime-local"
|
|
||||||
className="px-2 py-1 rounded-sm border border-regolith bg-white text-2xs font-mono"
|
|
||||||
// datetime-local требует local time без offset. toISOString() отдаёт
|
|
||||||
// UTC → у пользователя в +03:00 picker показывал значение на 3ч
|
|
||||||
// раньше выбранного, и на каждом редактировании round-trip drift'ил
|
|
||||||
// время. toDateTimeLocalInput / fromDateTimeLocalInput сохраняют
|
|
||||||
// wall-clock время через формат с явным local-TZ offset.
|
|
||||||
value={toDateTimeLocalInput(timeTravelAt)}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (!e.target.value) {
|
|
||||||
setTimeTravelAt(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setTimeTravelAt(fromDateTimeLocalInput(e.target.value))
|
|
||||||
}}
|
|
||||||
aria-label={t('timeTravel.label')}
|
|
||||||
/>
|
/>
|
||||||
{timeTravelAt && (
|
|
||||||
<>
|
|
||||||
<span className="text-2xs text-carbon/70 font-mono">
|
|
||||||
{new Date(timeTravelAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-ultramarain hover:underline"
|
|
||||||
onClick={() => setTimeTravelAt(undefined)}
|
|
||||||
>
|
|
||||||
{t('timeTravel.clear')}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-carbon/60 hover:underline ml-auto"
|
|
||||||
onClick={() => setTimeTravelOpen(false)}
|
|
||||||
>
|
|
||||||
{t('timeTravel.close')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active state — ambient banner если ?at= задан, даже если picker закрыт. */}
|
{/* Active state — ambient banner если ?at= задан, даже если picker закрыт. */}
|
||||||
@@ -745,7 +714,7 @@ function DictionaryDetail() {
|
|||||||
<EmptyState title={t('dict.filter.noMatches')} />
|
<EmptyState title={t('dict.filter.noMatches')} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{selection.size > 0 && (
|
{canMutate && selection.size > 0 && (
|
||||||
<BulkSelectionToolbar
|
<BulkSelectionToolbar
|
||||||
count={selection.size}
|
count={selection.size}
|
||||||
totalVisible={filteredCount}
|
totalVisible={filteredCount}
|
||||||
@@ -759,6 +728,7 @@ function DictionaryDetail() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
{canMutate && (
|
||||||
<TableHeaderCell>
|
<TableHeaderCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={t('dict.bulk.selectAll')}
|
aria-label={t('dict.bulk.selectAll')}
|
||||||
@@ -767,6 +737,7 @@ function DictionaryDetail() {
|
|||||||
onChange={toggleSelectAll}
|
onChange={toggleSelectAll}
|
||||||
/>
|
/>
|
||||||
</TableHeaderCell>
|
</TableHeaderCell>
|
||||||
|
)}
|
||||||
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
|
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
|
||||||
<TableHeaderCell>name / code</TableHeaderCell>
|
<TableHeaderCell>name / code</TableHeaderCell>
|
||||||
{extraColumns.map((c) => (
|
{extraColumns.map((c) => (
|
||||||
@@ -780,6 +751,7 @@ function DictionaryDetail() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{visibleRecords.map((r) => (
|
{visibleRecords.map((r) => (
|
||||||
<TableRow key={r.id} data-selected={selection.has(r.businessKey)}>
|
<TableRow key={r.id} data-selected={selection.has(r.businessKey)}>
|
||||||
|
{canMutate && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={`${t('dict.bulk.selectRow')} ${r.businessKey}`}
|
aria-label={`${t('dict.bulk.selectRow')} ${r.businessKey}`}
|
||||||
@@ -787,6 +759,7 @@ function DictionaryDetail() {
|
|||||||
onChange={() => toggleSelect(r.businessKey)}
|
onChange={() => toggleSelect(r.businessKey)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-mono text-2xs">{r.businessKey}</span>
|
<span className="font-mono text-2xs">{r.businessKey}</span>
|
||||||
@@ -844,6 +817,8 @@ function DictionaryDetail() {
|
|||||||
icon={<ClockCounterClockwiseIcon weight="regular" />}
|
icon={<ClockCounterClockwiseIcon weight="regular" />}
|
||||||
onClick={() => setHistoryKey(r.businessKey)}
|
onClick={() => setHistoryKey(r.businessKey)}
|
||||||
/>
|
/>
|
||||||
|
{canMutate && (
|
||||||
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
label={t('dict.action.edit')}
|
label={t('dict.action.edit')}
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -856,6 +831,8 @@ function DictionaryDetail() {
|
|||||||
icon={<XCircleIcon weight="regular" />}
|
icon={<XCircleIcon weight="regular" />}
|
||||||
onClick={() => setEdit({ kind: 'close-confirm', record: r })}
|
onClick={() => setEdit({ kind: 'close-confirm', record: r })}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@nstart/ui'
|
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@nstart/ui'
|
||||||
import { PlusIcon } from '@phosphor-icons/react'
|
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 type { DataScope, DictionaryDefinition, SchemaDependent } from '@/api/client'
|
||||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||||
|
import { useCanMutate } from '@/auth/useCanMutate'
|
||||||
import { SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
import { SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
||||||
|
|
||||||
// ===== Search params =====
|
// ===== Search params =====
|
||||||
@@ -101,6 +103,7 @@ function DictionariesPage() {
|
|||||||
const search = Route.useSearch()
|
const search = Route.useSearch()
|
||||||
const { data, isLoading, error } = useDictionaries()
|
const { data, isLoading, error } = useDictionaries()
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const canMutate = useCanMutate()
|
||||||
|
|
||||||
const q = (search.q ?? '').trim().toLowerCase()
|
const q = (search.q ?? '').trim().toLowerCase()
|
||||||
const deferredQuery = useDeferredValue(q)
|
const deferredQuery = useDeferredValue(q)
|
||||||
@@ -130,19 +133,40 @@ function DictionariesPage() {
|
|||||||
|
|
||||||
const setBundle = (bundle: string | undefined) => setSearch({ bundle })
|
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<string, number>()
|
||||||
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!data) return []
|
if (!data) return []
|
||||||
return data.filter((d) => {
|
return data.filter((d) => {
|
||||||
if (scopeFilter.size > 0 && !scopeFilter.has(d.scope)) return false
|
if (scopeFilter.size > 0 && !scopeFilter.has(d.scope)) return false
|
||||||
if (bundleFilter && d.bundle !== bundleFilter) return false
|
if (bundleFilter && d.bundle !== bundleFilter) return false
|
||||||
if (!matchesQuery(d, deferredQuery)) return false
|
if (!matchesQuery(d, deferredQuery)) return false
|
||||||
// withDepsOnly: пока нет FK metadata в DictionaryDefinition, фильтр
|
// «Со связями» — справочник используется в других справочниках (refBy > 0).
|
||||||
// эффективно no-op. После backend batch FK endpoint — заработает.
|
// Outgoing FK потребовал бы загрузки полной schema per dict — not worth
|
||||||
// TODO: backend пишет fk + refBy в DictionaryDefinition; раскомментить:
|
// the bandwidth для catalog filter. refBy достаточно для semantic.
|
||||||
// if (withDepsOnly && d.fk.length === 0 && d.refBy.length === 0) return false
|
if (withDepsOnly && (dependentsMap.get(d.name) ?? 0) === 0) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}, [data, deferredQuery, scopeFilter, bundleFilter])
|
}, [data, deferredQuery, scopeFilter, bundleFilter, withDepsOnly, dependentsMap])
|
||||||
|
|
||||||
const bundleCounts = useMemo(() => {
|
const bundleCounts = useMemo(() => {
|
||||||
const out = new Map<string, number>()
|
const out = new Map<string, number>()
|
||||||
@@ -160,7 +184,9 @@ function DictionariesPage() {
|
|||||||
const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly
|
const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly
|
||||||
const resetFilters = () => navigate({ search: {} })
|
const resetFilters = () => navigate({ search: {} })
|
||||||
|
|
||||||
const createButton = (
|
// Guest mode (anonymous) — кнопка «Создать справочник» скрыта. Backend всё
|
||||||
|
// равно вернёт 401 на POST, но UI hide избегает confusing UX.
|
||||||
|
const createButton = canMutate ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -170,7 +196,7 @@ function DictionariesPage() {
|
|||||||
>
|
>
|
||||||
{t('schema.action.create')}
|
{t('schema.action.create')}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
) : null
|
||||||
|
|
||||||
if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
|
if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user