455ca2ff26
После !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 теперь реально резолвит.
926 lines
34 KiB
TypeScript
926 lines
34 KiB
TypeScript
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>
|
||
)
|
||
}
|