Files
mdm-ordinis/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx
T
Zimin A.N. 7429cff009 fix(a11y): add sr-only DialogTitle/Description to record/export/timetravel modals
Radix Dialog warns when DialogContent отсутствует accessible name/description.
RecordDrawer (Sheet) уже имел Title, добавили sr-only Description.
ConflictDiffModal/ExportModal/TimeTravelModal — кастомные visible headers,
теперь имеют sr-only Title + Description для screen readers + чтобы
заглушить dev warnings.
2026-05-11 22:25:06 +03:00

550 lines
20 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 { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui'
import type { DictionaryDetail } from '@/api/client'
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>: Что изменилось / Записи на момент / Структура и поля.
* Backend diff endpoints отсутствуют — показываем empty/placeholder
* состояния с подсказкой backend pending.</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')
// 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={0}
/>
<TabBtn
active={tab === 'records'}
onClick={() => setTab('records')}
label={t('timeTravel.tabs.records', { defaultValue: 'Записи на момент' })}
count={totalRecords}
/>
<TabBtn
active={tab === 'schema'}
onClick={() => setTab('schema')}
label={t('timeTravel.tabs.schema', { defaultValue: 'Структура и поля' })}
/>
</div>
</div>
{/* Tab content */}
<div className="px-6 py-10 min-h-[180px] flex items-center justify-center text-mute text-body">
{tab === 'changes' && (
<span>
{isAtNow
? t('timeTravel.empty.same', {
defaultValue: 'Между этими версиями нет изменений.',
})
: t('timeTravel.placeholder.changes', {
defaultValue:
'Diff endpoint /api/v1/dictionaries/{name}/changelog · backend pending.',
})}
</span>
)}
{tab === 'records' && (
<span>
{pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '}
{totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')}
</span>
)}
{tab === 'schema' && (
<span>
v{detail.schemaVersion} ·{' '}
{Object.keys(detail.schemaJson.properties ?? {}).length}{' '}
{pluralRu(
Object.keys(detail.schemaJson.properties ?? {}).length,
'поле',
'поля',
'полей',
)}
</span>
)}
</div>
{/* 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">· {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 */}
<div className="relative h-4 mt-1 text-mono text-cap text-mute">
<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>
<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
}