From 775b8345e346747029b2bd2f59488df595652a39 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: Tue, 12 May 2026 13:00:03 +0000 Subject: [PATCH] feat(timetravel): Records + Schema tabs wired with real data --- .../components/timetravel/TimeTravelModal.tsx | 276 ++++++++++++++++-- ordinis-admin-ui/src/i18n.ts | 27 ++ 2 files changed, 282 insertions(+), 21 deletions(-) diff --git a/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx b/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx index 031171f..dd7d77c 100644 --- a/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx +++ b/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx @@ -2,8 +2,8 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react' import { Badge, Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui' -import type { ChangelogEntry, DictionaryDetail } from '@/api/client' -import { useChangelog } from '@/api/queries' +import type { ChangelogEntry, DictionaryDetail, FlattenedRecord } from '@/api/client' +import { useChangelog, useChangelogDiff, useRecords } from '@/api/queries' import { kindBadgeVariant, kindIcon, kindLabel } from '@/components/changelog/kind-meta' import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal' import { cn } from '@/lib/utils' @@ -88,6 +88,26 @@ export function TimeTravelModal({ ) }, [changelog.data, picked]) + // Find changelog entry which BROUGHT the dict to version active at `picked`: + // newest entry с publishedAt <= picked. Используется в Schema tab для + // "история этой версии" + diff.after schema fields. + const pickedVersionEntry = useMemo(() => { + if (!changelog.data) return null + const ordered = [...changelog.data.entries].sort( + (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(), + ) + return ordered.find((e) => new Date(e.publishedAt).getTime() <= picked) ?? null + }, [changelog.data, picked]) + + // Records as-of picked moment — backend findActiveAt(at) на read-api. + const isAtNow0 = Math.abs(picked - now) < 60_000 + const recordsQuery = useRecords( + detail.name, + 'PUBLIC,INTERNAL,RESTRICTED', + // Если picked ≈ now, не передаём `at` — экономим лишний DB hit + cache key. + isAtNow0 ? undefined : { at: new Date(picked).toISOString() }, + ) + // Range: от min(marks, createdAt) до now+padding. const createdMs = Date.parse(detail.createdAt) const range = useMemo(() => { @@ -271,7 +291,7 @@ export function TimeTravelModal({ active={tab === 'records'} onClick={() => setTab('records')} label={t('timeTravel.tabs.records', { defaultValue: 'Записи на момент' })} - count={totalRecords} + count={recordsQuery.data?.length ?? totalRecords} /> )} {tab === 'records' && ( -
- - {pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '} - {totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')} - -
+ )} {tab === 'schema' && ( -
- - v{detail.schemaVersion} ·{' '} - {Object.keys(detail.schemaJson.properties ?? {}).length}{' '} - {pluralRu( - Object.keys(detail.schemaJson.properties ?? {}).length, - 'поле', - 'поля', - 'полей', - )} - -
+ )} @@ -686,3 +699,224 @@ function ChangesTabContent({ ) } + +/** + * «Записи на момент» tab — table of records as-of picked moment. + * Uses {@code useRecords(at: pickedISO)} — backend findActiveAt(at). + * + *

Top 50 rendered. Если больше — показываем counter с pagination + * notice (full pagination не нужен в snapshot view). + */ +function RecordsTabContent({ + loading, + records, + pickedDate, +}: { + loading: boolean + records: FlattenedRecord[] + pickedDate: Date +}) { + const { t } = useTranslation() + + if (loading) { + return ( +

+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+ ) + } + if (records.length === 0) { + return ( +
+ {t('timeTravel.records.empty', { + date: pickedDate.toLocaleDateString(), + defaultValue: `На ${pickedDate.toLocaleDateString()} записей не было.`, + })} +
+ ) + } + + const visible = records.slice(0, 50) + const truncated = records.length > 50 + + return ( +
+
+ {t('timeTravel.records.title', { + count: records.length, + defaultValue: `${records.length} записей на ${pickedDate.toLocaleDateString()}`, + })} +
+
+ + + + + + + + + + + {visible.map((r) => { + const displayName = + (r.data['name_ru'] as string | undefined) ?? + (r.data['name'] as string | undefined) ?? + (r.data['name_en'] as string | undefined) ?? + '—' + return ( + + + + + + + ) + })} + +
businessKey + {t('timeTravel.records.col.name', { defaultValue: 'Название' })} + scope + {t('timeTravel.records.col.validFrom', { defaultValue: 'Действует с' })} +
{r.businessKey}{displayName}{r.dataScope} + {new Date(r.validFrom).toLocaleDateString()} +
+
+ {truncated && ( +
+ {t('timeTravel.records.truncated', { + visible: visible.length, + total: records.length, + defaultValue: `Показано ${visible.length} из ${records.length}. Открыть как readonly для полного списка.`, + })} +
+ )} +
+ ) +} + +/** + * «Структура и поля» tab — fields list at picked version + history panel + * на правой колонке. + * + *

Поля fetch'аются через ChangelogDiff endpoint (after schema). Если + * pickedEntry null — fallback на current schemaJson. + * + *

Right panel показывает changelog entry который attached к picked + * version: commit (audit id), автор, дата, headers/message. + */ +function SchemaTabContent({ + dictName, + currentSchema, + currentVersion, + pickedEntry, +}: { + dictName: string + currentSchema: { properties?: Record } + currentVersion: string + pickedEntry: ChangelogEntry | null +}) { + const { t } = useTranslation() + // Fetch full diff для picked entry чтобы получить historical `after` + // schema. Если pickedEntry — current/latest, after === current; иначе + // berunaround older snapshot. + const diff = useChangelogDiff(dictName, pickedEntry?.id) + const after = (diff.data?.after ?? null) as + | { properties?: Record } + | null + + // Fallback chain: diff.after → currentSchema. Эта семантика: + // "если для версии диффа нет → показываем live schema with disclaimer". + const propsSource = after ?? currentSchema + const fields = Object.keys(propsSource.properties ?? {}).sort() + const versionLabel = pickedEntry?.version ?? currentVersion + const isCurrentSchemaFallback = !after && Boolean(pickedEntry) + + return ( +

+ {/* LEFT — fields list */} +
+
+ {t('timeTravel.schema.fieldsTitle', { + version: versionLabel, + defaultValue: `Поля справочника на v${versionLabel}`, + })} +
+ {diff.isLoading && ( +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+ )} + {!diff.isLoading && fields.length === 0 && ( +
+ {t('timeTravel.schema.empty', { defaultValue: 'Полей нет.' })} +
+ )} + {!diff.isLoading && fields.length > 0 && ( +
    + {fields.map((f) => ( +
  • + {f} +
  • + ))} +
+ )} + {isCurrentSchemaFallback && ( +
+ {t('timeTravel.schema.fallback', { + defaultValue: 'Показана live-схема — diff на эту версию недоступен.', + })} +
+ )} +
+ + {/* RIGHT — history panel */} +
+
+ {t('timeTravel.schema.historyTitle', { + defaultValue: 'История этой версии', + })} +
+ {pickedEntry ? ( +
+
+
commit
+
#{pickedEntry.id}
+
+ {t('timeTravel.schema.author', { defaultValue: 'автор' })} +
+
{pickedEntry.publishedBy.name}
+
+ {t('timeTravel.schema.date', { defaultValue: 'дата' })} +
+
+ {new Date(pickedEntry.publishedAt).toLocaleString()} +
+
+ {pickedEntry.headers && ( +
+ «{pickedEntry.headers}» +
+ )} +
+ ) : ( +
+ {t('timeTravel.schema.noHistory', { + defaultValue: 'История недоступна для выбранного момента.', + })} +
+ )} +
+
+ ) +} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 3489ea5..813d03f 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -203,6 +203,20 @@ i18n 'queryError.generic.body': 'Что-то пошло не так. Попробуйте ещё раз.', 'timeTravel.empty.noEvents': 'Между этими моментами ничего не менялось.', 'timeTravel.action.diff': 'Diff', + 'timeTravel.records.empty': 'На {{date}} записей не было.', + 'timeTravel.records.title_one': '{{count}} запись на {{date}}', + 'timeTravel.records.title_few': '{{count}} записи на {{date}}', + 'timeTravel.records.title_many': '{{count}} записей на {{date}}', + 'timeTravel.records.col.name': 'Название', + 'timeTravel.records.col.validFrom': 'Действует с', + 'timeTravel.records.truncated': 'Показано {{visible}} из {{total}}. Откройте как readonly для полного списка.', + 'timeTravel.schema.fieldsTitle': 'Поля справочника на v{{version}}', + 'timeTravel.schema.empty': 'Полей нет.', + 'timeTravel.schema.fallback': 'Показана live-схема — diff на эту версию недоступен.', + 'timeTravel.schema.historyTitle': 'История этой версии', + 'timeTravel.schema.author': 'автор', + 'timeTravel.schema.date': 'дата', + 'timeTravel.schema.noHistory': 'История недоступна для выбранного момента.', 'webhooks.deliveries.col.eventId': 'Event ID', 'webhooks.deliveries.col.attempts': 'Попыток', 'webhooks.deliveries.col.lastAttempt': 'Последняя попытка', @@ -870,6 +884,19 @@ i18n 'queryError.generic.body': 'Something went wrong. Please try again.', 'timeTravel.empty.noEvents': 'Nothing changed between these moments.', 'timeTravel.action.diff': 'Diff', + 'timeTravel.records.empty': 'No records existed on {{date}}.', + 'timeTravel.records.title_one': '{{count}} record on {{date}}', + 'timeTravel.records.title_other': '{{count}} records on {{date}}', + 'timeTravel.records.col.name': 'Name', + 'timeTravel.records.col.validFrom': 'Valid from', + 'timeTravel.records.truncated': 'Showing {{visible}} of {{total}}. Open as readonly for the full list.', + 'timeTravel.schema.fieldsTitle': 'Dictionary fields at v{{version}}', + 'timeTravel.schema.empty': 'No fields.', + 'timeTravel.schema.fallback': 'Live schema shown — historical diff unavailable for this version.', + 'timeTravel.schema.historyTitle': 'History of this version', + 'timeTravel.schema.author': 'author', + 'timeTravel.schema.date': 'date', + 'timeTravel.schema.noHistory': 'History unavailable for the picked moment.', 'webhooks.deliveries.col.eventId': 'Event ID', 'webhooks.deliveries.col.attempts': 'Attempts', 'webhooks.deliveries.col.lastAttempt': 'Last attempt',