Files
mdm-ordinis/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx
T
Andrei Zimin 455ca2ff26 fix(ui): UserCell в changelog/events/history — ещё 6 мест
После !178 (record drawer + RecordHistoryDrawer + webhook detail) ещё
осталось 6 точек где раньше рендерился UUID. Все они в "истории
словаря" и tab "События":

1. ChangelogDiffModal:57 — header "автор: <name>" в diff modal.
   Было: `{data.publishedBy.name}` (тип ChangelogPublisher.name —
   docstring буквально говорит "Backend пока эхает sub").
   Стало: <UserCell uuid={data.publishedBy.sub} />.

2. TimeTravelModal:686 — список изменений, имя автора рядом с датой.
3. TimeTravelModal:898 — RIGHT panel "История этой версии", dt/dd
   pair с автором.
4. TimeTravelModal ComparePanel:414 — "· {author}" suffix,
   author там DictionaryDetail.updatedBy (тоже UUID).

5. dictionaries.$name.tsx:2263 (events tab, EventsTabContent row) —
   `{r.userId ?? 'anonymous'}` raw. Сменил на UserCell для не-null
   userId, anonymous-fallback оставил без UserCell (нечего lookup'ать).

6. dictionaries.$name.tsx:2399 (history tab, HistoryTabContent entry)
   — `{entry.publishedBy.name}` raw.

Всё через тот же useUserDisplay hook что и в !177 — backend
endpoint /admin/users/{sub}/display + JwtUserCaptureFilter cache.
Если sub нет в cache, UserCell fallback'ит на shortened UUID
(текущее поведение из !177).

ChangelogPublisher type comment в src/api/client.ts уже намекал что
.name это placeholder — useUserDisplay теперь реально резолвит.
2026-05-14 14:33:20 +03:00

926 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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 { UserCell } from '@/lib/useUserDisplay'
import { cn } from '@/lib/utils'
/**
* TimeTravelModal — fullscreen-y modal per redesign prototype.
*
* <p>Layout:
* <ul>
* <li><b>Header</b>: clock icon + TIME-TRAVEL · {DICT} cap + h1 title + ×</li>
* <li><b>Compare panels</b>: СЕЙЧАС vs ТОЧКА ВО ВРЕМЕНИ side-by-side.
* Right panel highlighted (`tint`) когда target != current.</li>
* <li><b>Quick presets</b>: сейчас / неделя / месяц / год / при создании</li>
* <li><b>Step toggle</b>: версия | день + nav arrows</li>
* <li><b>Slider</b>: HTML range + overlay version markers (top) + date
* markers (bottom). Cursor — dark circle.</li>
* <li><b>Tabs</b>: Что изменилось / Записи на момент / Структура и поля.
* «Что изменилось» — wired через {@code useChangelog} + filter
* events с {@code publishedAt >= picked}. Diff button открывает
* {@link ChangelogDiffModal} с full before/after JSON.</li>
* <li><b>Footer</b>: disclaimer + Закрыть / "Открыть v{X} как readonly" CTA</li>
* </ul>
*
* <p><b>Backend coverage:</b> findActiveAt(at) уже работает (read-api). Нет
* snapshots endpoint, нет diff. Поэтому version labels на slider — выводятся
* из schemaVersion текущей версии (single point), а исторические marks из
* record validFrom timestamps. "Открыть как readonly" просто применяет
* ?at=ISO в URL — route обрабатывает.
*/
export type TimeTravelModalProps = {
open: boolean
onClose: () => void
detail: DictionaryDetail
/** Currently applied at-ISO (если активен readonly snapshot). */
value: string | undefined
/** Apply the picked moment: opens dict as readonly (route uses ?at=). */
onApply: (iso: string) => void
/** Clear at — return к live view. */
onClear: () => void
/** Unique record validFrom timestamps (ms) — для slider tick marks. */
marks: number[]
/** Total records на текущий момент — для tab counter. */
totalRecords: number
}
type StepMode = 'version' | 'day'
export function TimeTravelModal({
open,
onClose,
detail,
value,
onApply,
onClear,
marks,
totalRecords,
}: TimeTravelModalProps) {
const { t } = useTranslation()
const now = Date.now()
const valueMs = useMemo(() => {
if (!value) return now
const ms = Date.parse(value)
return Number.isNaN(ms) ? now : ms
}, [value, now])
const [picked, setPicked] = useState<number>(valueMs)
const [step, setStep] = useState<StepMode>('version')
const [tab, setTab] = useState<'changes' | 'records' | 'schema'>('changes')
/** Open ChangelogDiffModal для конкретной entry — full before/after JSON. */
const [diffEntryId, setDiffEntryId] = useState<number | undefined>(undefined)
// Changelog для compare-since-picked: показываем events после picked moment.
// Размер 200 покрывает типичные dict'ы (5-50 events); cursor pagination
// можно добавить когда что-то будет с этим больше.
const changelog = useChangelog(detail.name, 200)
const eventsSincePicked = useMemo<ChangelogEntry[]>(() => {
if (!changelog.data) return []
return changelog.data.entries.filter(
(e) => new Date(e.publishedAt).getTime() >= picked,
)
}, [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<ChangelogEntry | null>(() => {
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(() => {
const minMark = marks.length > 0 ? Math.min(...marks, createdMs) : createdMs - 86_400_000
const span = now - minMark
const pad = Math.max(span * 0.04, 86_400_000)
return { min: minMark - pad, max: now + pad }
}, [marks, createdMs, now])
// Sync picked when value prop changes while modal open.
// (Don't useEffect — пока explicitly value control.)
const pickedDate = new Date(picked)
const isAtNow = Math.abs(picked - now) < 60_000 // < 1 min ≈ now
const targetVersion = detail.schemaVersion // backend doesn't expose snapshots
const targetDiffers = !isAtNow
const handlePreset = (deltaMs: number | 'create') => {
if (deltaMs === 'create') {
setPicked(createdMs)
} else if (deltaMs === 0) {
setPicked(now)
} else {
setPicked(now + deltaMs)
}
}
const handleStepNav = (direction: -1 | 1) => {
if (step === 'day') {
setPicked((p) => Math.min(range.max, Math.max(range.min, p + direction * 86_400_000)))
} else {
// Version step: jump to nearest mark
const sorted = [...marks].sort((a, b) => a - b)
if (sorted.length === 0) return
if (direction === -1) {
const prev = [...sorted].reverse().find((m) => m < picked - 1000)
if (prev !== undefined) setPicked(prev)
} else {
const next = sorted.find((m) => m > picked + 1000)
if (next !== undefined) setPicked(next)
}
}
}
const handleApply = () => {
if (isAtNow) {
onClear()
} else {
onApply(new Date(picked).toISOString())
}
onClose()
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent size="full" className="!p-0 gap-0 max-h-[95vh]" hideClose>
{/* Radix a11y: sr-only Title + Description. Visible header — custom layout ниже. */}
<DialogTitle className="sr-only">
{t('timeTravel.title', { defaultValue: 'Состояние справочника на момент времени' })}
</DialogTitle>
<DialogDescription className="sr-only">
{t('timeTravel.description', {
defaultValue:
'Сравнение текущего состояния справочника с выбранной точкой во времени.',
})}
</DialogDescription>
{/* Header */}
<div className="flex items-start gap-3 px-6 py-4 border-b border-line">
<div className="size-8 rounded-md bg-surface-2 inline-flex items-center justify-center shrink-0">
<ClockCounterClockwiseIcon weight="regular" size={18} className="text-ink-2" />
</div>
<div className="min-w-0 flex-1">
<div className="text-cap text-mute tracking-[0.18em] uppercase">
Time-travel
<span className="mx-2 text-line">·</span>
<span className="text-ink">{detail.name.toUpperCase()}</span>
</div>
<h2 className="text-title-md text-ink font-semibold mt-0.5">
{t('timeTravel.title', { defaultValue: 'Состояние справочника на момент времени' })}
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label={t('common.close', { defaultValue: 'Закрыть' })}
className="rounded-sm p-1 text-mute hover:text-ink hover:bg-surface-2 transition-colors"
>
<XIcon weight="bold" size={18} />
</button>
</div>
{/* Compare panels */}
<div className="grid grid-cols-2 border-b border-line">
<ComparePanel
label={t('timeTravel.compare.now', { defaultValue: 'Сейчас' })}
version={detail.schemaVersion}
ms={now}
records={totalRecords}
author={(detail as DictionaryDetail & { updatedBy?: string }).updatedBy}
/>
<ComparePanel
label={
<>
{t('timeTravel.compare.point', { defaultValue: 'Точка во времени' })}
{targetDiffers && (
<span className="ml-2 text-pink text-cap tracking-wider">
· {t('timeTravel.compare.diff', { defaultValue: 'отличается' })}
</span>
)}
</>
}
version={targetVersion}
ms={picked}
records={totalRecords /* backend ?at= даст реальный count при заходе */}
author={(detail as DictionaryDetail & { updatedBy?: string }).updatedBy}
note={isAtNow ? '«current»' : undefined}
highlight={targetDiffers}
/>
</div>
{/* Quick presets + step toggle */}
<div className="px-6 py-3 border-b border-line-2 flex items-center gap-3 flex-wrap">
<div className="text-cap text-mute tracking-[0.16em] uppercase">
{t('timeTravel.quick', { defaultValue: 'Быстро' })}
</div>
<div className="flex items-center gap-2 flex-wrap">
<Preset label="сейчас" onClick={() => handlePreset(0)} />
<Preset label="неделя назад" onClick={() => handlePreset(-7 * 86_400_000)} />
<Preset label="месяц назад" onClick={() => handlePreset(-30 * 86_400_000)} />
<Preset label="год назад" onClick={() => handlePreset(-365 * 86_400_000)} />
<Preset label="при создании" onClick={() => handlePreset('create')} />
</div>
<div className="ml-auto flex items-center gap-2">
<div className="text-cap text-mute tracking-[0.16em] uppercase mr-1">
{t('timeTravel.step', { defaultValue: 'Шаг' })}
</div>
<div className="inline-flex rounded-md border border-line bg-surface overflow-hidden">
<StepBtn active={step === 'version'} onClick={() => setStep('version')} label="версия" />
<StepBtn active={step === 'day'} onClick={() => setStep('day')} label="день" />
</div>
<button
type="button"
onClick={() => handleStepNav(-1)}
className="size-8 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
aria-label="prev"
>
<CaretLeftIcon weight="bold" size={14} />
</button>
<button
type="button"
onClick={() => handleStepNav(1)}
className="size-8 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
aria-label="next"
>
<CaretRightIcon weight="bold" size={14} />
</button>
</div>
</div>
{/* Slider */}
<div className="px-6 pt-5 pb-3 border-b border-line bg-surface-2/40">
<TimelineSlider
range={range}
marks={marks}
value={picked}
onChange={setPicked}
currentVersion={detail.schemaVersion}
/>
</div>
{/* Tabs */}
<div className="px-6 pt-3 border-b border-line">
<div className="flex items-end gap-6 -mb-px">
<TabBtn
active={tab === 'changes'}
onClick={() => setTab('changes')}
label={t('timeTravel.tabs.changes', { defaultValue: 'Что изменилось' })}
count={eventsSincePicked.length}
/>
<TabBtn
active={tab === 'records'}
onClick={() => setTab('records')}
label={t('timeTravel.tabs.records', { defaultValue: 'Записи на момент' })}
count={recordsQuery.data?.length ?? totalRecords}
/>
<TabBtn
active={tab === 'schema'}
onClick={() => setTab('schema')}
label={t('timeTravel.tabs.schema', { defaultValue: 'Структура и поля' })}
/>
</div>
</div>
{/* Tab content */}
<div className="px-6 py-6 min-h-[180px]">
{tab === 'changes' && (
<ChangesTabContent
isAtNow={isAtNow}
loading={changelog.isLoading}
events={eventsSincePicked}
onOpenDiff={(id) => setDiffEntryId(id)}
/>
)}
{tab === 'records' && (
<RecordsTabContent
loading={recordsQuery.isLoading}
records={recordsQuery.data ?? []}
pickedDate={pickedDate}
/>
)}
{tab === 'schema' && (
<SchemaTabContent
dictName={detail.name}
currentSchema={detail.schemaJson}
currentVersion={detail.schemaVersion}
pickedEntry={pickedVersionEntry}
/>
)}
</div>
{/* Per-event diff modal — fires lazy fetch /changelog/{id}/diff. */}
<ChangelogDiffModal
open={diffEntryId !== undefined}
onClose={() => setDiffEntryId(undefined)}
dictionaryName={detail.name}
entryId={diffEntryId}
/>
{/* Footer */}
<div className="px-6 py-3 border-t border-line flex items-center justify-between gap-3 mt-auto">
<div className="text-cap text-mute tracking-[0.14em] uppercase">
{t('timeTravel.disclaimer', {
defaultValue: 'Snapshot не меняет данные — только показывает',
})}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 hover:border-line-2 text-body transition-colors"
>
{t('common.close', { defaultValue: 'Закрыть' })}
</button>
<button
type="button"
onClick={handleApply}
className="h-9 px-4 rounded-md bg-navy text-on-accent hover:opacity-90 text-body font-semibold transition-opacity"
>
{isAtNow
? t('timeTravel.applyNow', { defaultValue: 'Закрыть snapshot' })
: t('timeTravel.apply', {
defaultValue: `Открыть v${targetVersion} как readonly`,
})}
</button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
function ComparePanel({
label,
version,
ms,
records,
author,
note,
highlight,
}: {
label: React.ReactNode
version: string
ms: number
records: number
author?: string
note?: string
highlight?: boolean
}) {
const d = new Date(ms)
return (
<div
className={cn(
'px-6 py-4 space-y-1.5 border-r border-line last:border-r-0',
highlight && 'bg-warn-bg/40',
)}
>
<div className="text-cap text-mute tracking-[0.18em] uppercase">{label}</div>
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-mono text-title-md text-ink font-semibold">v{version}</span>
<span className="text-mono text-cell text-mute">
{d.toLocaleDateString(undefined, { dateStyle: 'short' })}
{' · '}
{d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="text-cell text-ink-2 flex items-center gap-1.5 flex-wrap">
<span className="tabular-nums">{records}</span>
<span>{pluralRu(records, 'запись', 'записи', 'записей')}</span>
{note && (
<span className="text-mute">
· <span className="text-mono">{note}</span>
</span>
)}
{author && (
<span className="text-mute inline-flex items-center gap-1">· <UserCell uuid={author} /></span>
)}
</div>
</div>
)
}
function Preset({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="h-8 px-3 rounded-full border border-line bg-surface text-cell text-ink-2 hover:border-ink-2 hover:bg-surface-2 transition-colors"
>
{label}
</button>
)
}
function StepBtn({
active,
onClick,
label,
}: {
active: boolean
onClick: () => void
label: string
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
'h-8 px-3 text-body border-r border-line last:border-r-0 transition-colors',
active ? 'bg-navy text-on-accent font-semibold' : 'bg-surface text-ink-2 hover:bg-surface-2',
)}
>
{label}
</button>
)
}
function TabBtn({
active,
onClick,
label,
count,
}: {
active: boolean
onClick: () => void
label: string
count?: number
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
'pb-2 text-body inline-flex items-center gap-1.5 border-b-2 transition-colors',
active
? 'border-ink text-ink font-semibold'
: 'border-transparent text-ink-2 hover:text-ink',
)}
>
{label}
{count !== undefined && (
<span className={cn('text-mono', active ? 'text-mute' : 'text-mute')}>{count}</span>
)}
</button>
)
}
// ===== Slider =============================================================
function TimelineSlider({
range,
marks,
value,
onChange,
currentVersion,
}: {
range: { min: number; max: number }
marks: number[]
value: number
onChange: (ms: number) => void
currentVersion: string
}) {
const span = range.max - range.min || 1
const pct = ((value - range.min) / span) * 100
const dateLabel = new Date(value).toLocaleDateString(undefined, {
month: '2-digit',
day: '2-digit',
})
return (
<div className="relative w-full">
{/* Version labels (top) — single current marker для текущей schema version.
* Когда backend получит snapshots endpoint — здесь будут все версии. */}
<div className="relative h-5 mb-1">
<span
className="absolute -translate-x-1/2 text-mono text-cap text-accent font-semibold"
style={{ left: '100%' }}
>
v{currentVersion}
</span>
</div>
{/* Track + cursor (range input для drag UX) */}
<div className="relative h-6">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[2px] bg-accent/60 rounded-full" />
{/* Mark ticks */}
{marks.map((m) => {
const p = ((m - range.min) / span) * 100
if (p < 0 || p > 100) return null
return (
<span
key={m}
className="absolute top-1/2 -translate-y-1/2 w-px h-3 bg-accent/40"
style={{ left: `${p}%` }}
/>
)
})}
{/* Cursor */}
<span
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 size-5 rounded-full bg-ink border-2 border-surface shadow-md pointer-events-none"
style={{ left: `${pct}%` }}
/>
{/* Hidden range input для drag */}
<input
type="range"
min={range.min}
max={range.max}
step={86_400_000} // day
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="absolute inset-0 opacity-0 cursor-pointer"
aria-label="time-travel slider"
/>
</div>
{/* Date labels (bottom) — start / value / now.
* Boundary-метки (min/max) скрываются, когда курсор подъехал близко,
* иначе текст накладывается в "05.005.05". Порог 12% подобран так,
* чтобы две DD.MM метки ~ 36px @ text-cap не пересекались на типичной
* ширине трека ~340px. */}
<div className="relative h-4 mt-1 text-mono text-cap text-mute">
{pct > 12 && (
<span className="absolute left-0">
{new Date(range.min).toLocaleDateString(undefined, {
month: '2-digit',
day: '2-digit',
})}
</span>
)}
<span
className="absolute -translate-x-1/2 text-accent font-semibold"
style={{ left: `${pct}%` }}
>
{dateLabel}
</span>
{pct < 88 && (
<span className="absolute right-0">
{new Date(range.max).toLocaleDateString(undefined, {
month: '2-digit',
day: '2-digit',
})}
</span>
)}
</div>
</div>
)
}
function pluralRu(n: number, one: string, few: string, many: string): string {
const mod10 = n % 10
const mod100 = n % 100
if (mod10 === 1 && mod100 !== 11) return one
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few
return many
}
/**
* «Что изменилось» tab — список changelog events которые произошли
* после picked moment. Каждый event с kind-aware icon/badge + summary
* counts + кнопка «Diff» открывает {@link ChangelogDiffModal} с full
* before/after JSON.
*
* <p>isAtNow={true} → empty state «нет изменений». isLoading → skeleton.
* events=[] → «между этими версиями нет изменений».
*/
function ChangesTabContent({
isAtNow,
loading,
events,
onOpenDiff,
}: {
isAtNow: boolean
loading: boolean
events: ChangelogEntry[]
onOpenDiff: (id: number) => void
}) {
const { t } = useTranslation()
if (isAtNow) {
return (
<div className="flex items-center justify-center text-mute text-body">
{t('timeTravel.empty.same', {
defaultValue: 'Между этими версиями нет изменений.',
})}
</div>
)
}
if (loading) {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-12 rounded-lg border border-line bg-surface-2/40 animate-pulse"
/>
))}
</div>
)
}
if (events.length === 0) {
return (
<div className="flex items-center justify-center text-mute text-body">
{t('timeTravel.empty.noEvents', {
defaultValue: 'Между этими моментами ничего не менялось.',
})}
</div>
)
}
return (
<ul className="space-y-2">
{events.map((e) => {
const Icon = kindIcon(e.kind)
const added = e.diff.added.length
const changed = e.diff.changed.length
const removed = e.diff.removed.length
return (
<li
key={e.id}
className="rounded-lg border border-line bg-surface p-3 flex items-start gap-3"
>
<Icon weight="duotone" size={20} className="text-mute shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
{e.version && (
<span className="text-mono text-cell text-ink font-semibold tabular-nums">
v{e.version}
</span>
)}
<Badge variant={kindBadgeVariant(e.kind)}>{kindLabel(e.kind, t)}</Badge>
<span className="text-mono text-cap text-mute tabular-nums ml-auto">
{added > 0 && <span className="text-aurora">+{added}</span>}
{added > 0 && (changed > 0 || removed > 0) && ' '}
{changed > 0 && <span className="text-warn">~{changed}</span>}
{changed > 0 && removed > 0 && ' '}
{removed > 0 && <span className="text-danger">{removed}</span>}
</span>
</div>
{e.headers && (
<div className="text-body text-ink mt-0.5 truncate">{e.headers}</div>
)}
<div className="flex items-center gap-3 mt-1 text-cell text-mute">
<span className="tabular-nums">
{new Date(e.publishedAt).toLocaleString()}
</span>
<UserCell uuid={e.publishedBy.sub} />
<button
type="button"
onClick={() => onOpenDiff(e.id)}
className="ml-auto text-accent hover:underline"
>
{t('timeTravel.action.diff', { defaultValue: 'Diff' })}
</button>
</div>
</div>
</li>
)
})}
</ul>
)
}
/**
* «Записи на момент» tab — table of records as-of picked moment.
* Uses {@code useRecords(at: pickedISO)} — backend findActiveAt(at).
*
* <p>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 (
<div className="space-y-1">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-9 rounded-md border border-line bg-surface-2/40 animate-pulse"
/>
))}
</div>
)
}
if (records.length === 0) {
return (
<div className="flex items-center justify-center text-mute text-body py-10">
{t('timeTravel.records.empty', {
date: pickedDate.toLocaleDateString(),
defaultValue: `На ${pickedDate.toLocaleDateString()} записей не было.`,
})}
</div>
)
}
const visible = records.slice(0, 50)
const truncated = records.length > 50
return (
<div className="space-y-2">
<div className="text-cap text-mute uppercase tracking-wider">
{t('timeTravel.records.title', {
count: records.length,
defaultValue: `${records.length} записей на ${pickedDate.toLocaleDateString()}`,
})}
</div>
<div className="rounded-md border border-line overflow-hidden">
<table className="w-full text-cell">
<thead className="bg-surface-2 text-cap text-mute uppercase tracking-wider">
<tr>
<th className="text-left px-3 py-2">businessKey</th>
<th className="text-left px-3 py-2">
{t('timeTravel.records.col.name', { defaultValue: 'Название' })}
</th>
<th className="text-left px-3 py-2">scope</th>
<th className="text-left px-3 py-2">
{t('timeTravel.records.col.validFrom', { defaultValue: 'Действует с' })}
</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={r.id} className="border-t border-line">
<td className="px-3 py-2 font-mono">{r.businessKey}</td>
<td className="px-3 py-2 text-ink truncate max-w-[300px]">{displayName}</td>
<td className="px-3 py-2 text-cap">{r.dataScope}</td>
<td className="px-3 py-2 tabular-nums text-mute">
{new Date(r.validFrom).toLocaleDateString()}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{truncated && (
<div className="text-cap text-mute italic">
{t('timeTravel.records.truncated', {
visible: visible.length,
total: records.length,
defaultValue: `Показано ${visible.length} из ${records.length}. Открыть как readonly для полного списка.`,
})}
</div>
)}
</div>
)
}
/**
* «Структура и поля» tab — fields list at picked version + history panel
* на правой колонке.
*
* <p>Поля fetch'аются через ChangelogDiff endpoint (after schema). Если
* pickedEntry null — fallback на current schemaJson.
*
* <p>Right panel показывает changelog entry который attached к picked
* version: commit (audit id), автор, дата, headers/message.
*/
function SchemaTabContent({
dictName,
currentSchema,
currentVersion,
pickedEntry,
}: {
dictName: string
currentSchema: { properties?: Record<string, unknown> }
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<string, unknown> }
| 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 (
<div className="grid grid-cols-1 md:grid-cols-[1fr_minmax(220px,300px)] gap-6">
{/* LEFT — fields list */}
<div className="space-y-2">
<div className="text-cap text-mute uppercase tracking-wider">
{t('timeTravel.schema.fieldsTitle', {
version: versionLabel,
defaultValue: `Поля справочника на v${versionLabel}`,
})}
</div>
{diff.isLoading && (
<div className="space-y-1">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-8 rounded-md border border-line bg-surface-2/40 animate-pulse"
/>
))}
</div>
)}
{!diff.isLoading && fields.length === 0 && (
<div className="text-cell text-mute py-4 text-center">
{t('timeTravel.schema.empty', { defaultValue: 'Полей нет.' })}
</div>
)}
{!diff.isLoading && fields.length > 0 && (
<ul className="rounded-md border border-line divide-y divide-line">
{fields.map((f) => (
<li key={f} className="px-3 py-2 font-mono text-cell text-ink">
{f}
</li>
))}
</ul>
)}
{isCurrentSchemaFallback && (
<div className="text-cap text-mute italic">
{t('timeTravel.schema.fallback', {
defaultValue: 'Показана live-схема — diff на эту версию недоступен.',
})}
</div>
)}
</div>
{/* RIGHT — history panel */}
<div className="space-y-2">
<div className="text-cap text-mute uppercase tracking-wider">
{t('timeTravel.schema.historyTitle', {
defaultValue: 'История этой версии',
})}
</div>
{pickedEntry ? (
<dl className="rounded-md border border-line bg-surface p-3 space-y-2 text-cell">
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
<dt className="text-cap text-mute uppercase tracking-wider">commit</dt>
<dd className="font-mono text-accent">#{pickedEntry.id}</dd>
<dt className="text-cap text-mute uppercase tracking-wider">
{t('timeTravel.schema.author', { defaultValue: 'автор' })}
</dt>
<dd><UserCell uuid={pickedEntry.publishedBy.sub} /></dd>
<dt className="text-cap text-mute uppercase tracking-wider">
{t('timeTravel.schema.date', { defaultValue: 'дата' })}
</dt>
<dd className="tabular-nums">
{new Date(pickedEntry.publishedAt).toLocaleString()}
</dd>
</div>
{pickedEntry.headers && (
<div className="text-body text-ink mt-2 italic">
«{pickedEntry.headers}»
</div>
)}
</dl>
) : (
<div className="text-cell text-mute italic">
{t('timeTravel.schema.noHistory', {
defaultValue: 'История недоступна для выбранного момента.',
})}
</div>
)}
</div>
</div>
)
}