feat(changelog): wire up History tab to /changelog endpoint + diff modal

This commit is contained in:
Александр Зимин
2026-05-12 10:49:53 +00:00
parent ba04c2199e
commit 57be0d88d3
6 changed files with 614 additions and 38 deletions
+72
View File
@@ -482,3 +482,75 @@ export type WebhookTestPingResult = {
latencyMs?: number | null latencyMs?: number | null
message?: string | null message?: string | null
} }
// === Changelog (v2.4.0 + full timeline + diff) ===
/**
* Frontend-friendly event kind. Backend mapping см.
* ChangelogService.mapEventTypeToKind. Используем для icon/color/label
* выбора в History tab. Versioning events
* (definition_create / definition_update / draft_publish) показываются
* с version label; promotional draft events идут без version.
*/
export type ChangelogKind =
| 'definition_create'
| 'definition_update'
| 'draft_create'
| 'draft_review'
| 'draft_approve'
| 'draft_reject'
| 'draft_changes_requested'
| 'draft_publish'
| 'draft_withdraw'
export type ChangelogPublisher = {
/** JWT sub claim (Keycloak user id). */
sub: string
/** Display name. Backend пока эхает sub; готовим место под user-resolve. */
name: string
}
export type ChangelogDiffSummary = {
added: string[]
removed: string[]
changed: string[]
}
export type ChangelogEntry = {
/** audit_log.id — deep-link на diff endpoint. */
id: number
/** Semver. null для promotional draft events. */
version: string | null
publishedAt: string
publishedBy: ChangelogPublisher
/** Header summary text для card UI. */
headers: string
diff: ChangelogDiffSummary
recordsAffected: number
isCurrent: boolean
kind: ChangelogKind
}
export type ChangelogResponse = {
dictionary: string
currentVersion: string
entries: ChangelogEntry[]
}
/**
* Full before/after schemas для side-by-side render. before=null для
* definition_create. Для draft_* (не publish) — after=proposed_schema,
* before=live schema на момент event'а (то что предлагалось vs реальность).
*/
export type ChangelogDiff = {
id: number
dictionaryName: string
/** Raw audit event_type (SCHEMA_UPDATED / SCHEMA_DRAFT_PUBLISHED / ...). */
eventType: string
kind: ChangelogKind
occurredAt: string
publishedBy: ChangelogPublisher
before: unknown | null
after: unknown | null
summary: ChangelogDiffSummary
}
+54
View File
@@ -4,6 +4,8 @@ import {
type AuditFilters, type AuditFilters,
type AuditPage, type AuditPage,
type CascadePlan, type CascadePlan,
type ChangelogDiff,
type ChangelogResponse,
type DictionaryDefinition, type DictionaryDefinition,
type DictionaryDetail, type DictionaryDetail,
type DlqPage, type DlqPage,
@@ -138,6 +140,58 @@ export const useRecordHistory = (
enabled: Boolean(businessKey), enabled: Boolean(businessKey),
}) })
// === Changelog (schema versions timeline) ============================
/**
* Per-dict changelog. Backend default limit=50; покрывает типичные ~5-30
* schema versions без pagination. Cursor можно добавить позже без
* breaking change.
*/
export const changelogQuery = (dictionaryName: string, limit = 50) =>
queryOptions({
queryKey: ['changelog', dictionaryName, limit] as const,
queryFn: async (): Promise<ChangelogResponse> => {
const { data } = await apiClient.get<ChangelogResponse>(
`/dictionaries/${dictionaryName}/changelog`,
{ params: { limit } },
)
return data
},
})
export const useChangelog = (dictionaryName: string | undefined, limit = 50) =>
useQuery({
...changelogQuery(dictionaryName ?? '', limit),
enabled: Boolean(dictionaryName),
})
/**
* Full before/after diff payload для одного changelog entry'я. Lazy —
* fetch только когда юзер раскрывает Compare modal. Кешируем по
* (dict, entryId) — diff immutable, можно держать stale infinitely
* (audit_log row не меняется).
*/
export const changelogDiffQuery = (dictionaryName: string, entryId: number) =>
queryOptions({
queryKey: ['changelog-diff', dictionaryName, entryId] as const,
queryFn: async (): Promise<ChangelogDiff> => {
const { data } = await apiClient.get<ChangelogDiff>(
`/dictionaries/${dictionaryName}/changelog/${entryId}/diff`,
)
return data
},
staleTime: Infinity,
})
export const useChangelogDiff = (
dictionaryName: string | undefined,
entryId: number | undefined,
) =>
useQuery({
...changelogDiffQuery(dictionaryName ?? '', entryId ?? 0),
enabled: Boolean(dictionaryName) && Boolean(entryId),
})
export const recordRawQuery = (dictionaryName: string, businessKey: string) => export const recordRawQuery = (dictionaryName: string, businessKey: string) =>
queryOptions({ queryOptions({
queryKey: ['record-raw', dictionaryName, businessKey] as const, queryKey: ['record-raw', dictionaryName, businessKey] as const,
@@ -0,0 +1,219 @@
import { useTranslation } from 'react-i18next'
import { Alert, Badge, LoadingBlock, Modal } from '@/ui'
import { useChangelogDiff } from '@/api/queries'
import { kindBadgeVariant, kindLabel } from './kind-meta'
/**
* Side-by-side schema diff для одного changelog event'а. Lazy fetch'ит
* полный before/after через `useChangelogDiff` когда modal открыт.
*
* <p>Layout:
* <ul>
* <li>Header — event metadata (kind badge, version, дата, автор).</li>
* <li>Summary stripe — counts added/removed/changed properties.</li>
* <li>Two-column JSON panes (before | after). Pre-monospace, scrollable.</li>
* </ul>
*
* <p>На narrow viewport панели stack'аются вертикально.
*/
export function ChangelogDiffModal({
open,
onClose,
dictionaryName,
entryId,
}: {
open: boolean
onClose: () => void
dictionaryName: string
entryId: number | undefined
}) {
const { t } = useTranslation()
const { data, isLoading, error } = useChangelogDiff(
open ? dictionaryName : undefined,
open ? entryId : undefined,
)
return (
<Modal
isOpen={open}
onClose={onClose}
size="full"
title={t('changelog.diff.title', { defaultValue: 'Изменения схемы' })}
description={
data
? `${data.dictionaryName} · ${new Date(data.occurredAt).toLocaleString()}`
: undefined
}
>
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
{error && (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)}
{data && (
<div className="space-y-4">
{/* Header strip — kind + author */}
<div className="flex flex-wrap items-center gap-2">
<Badge variant={kindBadgeVariant(data.kind)}>{kindLabel(data.kind, t)}</Badge>
<span className="text-cell text-mute">
{t('changelog.diff.by', { defaultValue: 'автор' })}:{' '}
<span className="text-ink font-mono">{data.publishedBy.name}</span>
</span>
</div>
{/* Summary counts */}
<ChangelogSummary
added={data.summary.added.length}
removed={data.summary.removed.length}
changed={data.summary.changed.length}
/>
{/* Field-level lists (collapsed by default — info dense) */}
{(data.summary.added.length > 0 ||
data.summary.removed.length > 0 ||
data.summary.changed.length > 0) && (
<div className="rounded-lg border border-line bg-surface p-3 space-y-2 text-cell">
{data.summary.added.length > 0 && (
<PropList
label={t('changelog.diff.added', { defaultValue: 'Добавлены' })}
items={data.summary.added}
tone="success"
/>
)}
{data.summary.removed.length > 0 && (
<PropList
label={t('changelog.diff.removed', { defaultValue: 'Удалены' })}
items={data.summary.removed}
tone="danger"
/>
)}
{data.summary.changed.length > 0 && (
<PropList
label={t('changelog.diff.changed', { defaultValue: 'Изменены' })}
items={data.summary.changed}
tone="warn"
/>
)}
</div>
)}
{/* Side-by-side JSON */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<JsonPane
title={t('changelog.diff.before', { defaultValue: 'До' })}
data={data.before}
empty={t('changelog.diff.beforeEmpty', {
defaultValue: '— (первоначальное создание словаря)',
})}
/>
<JsonPane
title={t('changelog.diff.after', { defaultValue: 'После' })}
data={data.after}
empty={t('changelog.diff.afterEmpty', {
defaultValue: '— (нет proposed schema для этого события)',
})}
/>
</div>
</div>
)}
</Modal>
)
}
function ChangelogSummary({
added,
removed,
changed,
}: {
added: number
removed: number
changed: number
}) {
return (
<div className="flex flex-wrap items-center gap-3 text-mono text-cap tabular-nums">
<SummaryChip label="+" count={added} tone="success" />
<SummaryChip label="" count={removed} tone="danger" />
<SummaryChip label="~" count={changed} tone="warn" />
</div>
)
}
function SummaryChip({
label,
count,
tone,
}: {
label: string
count: number
tone: 'success' | 'danger' | 'warn'
}) {
const cls =
tone === 'success'
? 'text-aurora'
: tone === 'danger'
? 'text-danger'
: 'text-warn'
return (
<span className={`inline-flex items-center gap-1 ${cls}`}>
<span className="font-bold">{label}</span>
<span>{count}</span>
</span>
)
}
function PropList({
label,
items,
tone,
}: {
label: string
items: string[]
tone: 'success' | 'danger' | 'warn'
}) {
const dotCls =
tone === 'success'
? 'bg-aurora'
: tone === 'danger'
? 'bg-danger'
: 'bg-warn'
return (
<div>
<div className="text-cap font-semibold uppercase tracking-wider text-mute mb-1">
{label} ({items.length})
</div>
<ul className="flex flex-wrap gap-1.5">
{items.map((p) => (
<li
key={p}
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-sm border border-line bg-surface-2/40"
>
<span className={`size-1.5 rounded-full ${dotCls}`} aria-hidden />
<code className="font-mono text-cell">{p}</code>
</li>
))}
</ul>
</div>
)
}
function JsonPane({
title,
data,
empty,
}: {
title: string
data: unknown | null
empty: string
}) {
return (
<div className="rounded-lg border border-line bg-surface overflow-hidden flex flex-col">
<div className="px-3 py-2 border-b border-line text-cap font-semibold uppercase tracking-wider text-mute">
{title}
</div>
<pre className="text-mono text-cell p-3 overflow-auto max-h-[60vh] bg-[#0c0a23] text-[#f0eaff]">
{data === null || data === undefined ? empty : JSON.stringify(data, null, 2)}
</pre>
</div>
)
}
@@ -0,0 +1,96 @@
import {
PencilSimpleIcon,
PlusCircleIcon,
CheckCircleIcon,
XCircleIcon,
ArrowsCounterClockwiseIcon,
RocketIcon,
TrashIcon,
ChatCircleTextIcon,
EyeIcon,
type Icon,
} from '@phosphor-icons/react'
import type { ChangelogKind } from '@/api/client'
import type { BadgeProps } from '@/ui'
/**
* Metadata mapping для ChangelogKind: icon, badge variant, локализуемый
* label. Один источник для History tab card + Diff modal header.
*
* Versioning events (definition_*, draft_publish) и draft promotional
* events различаются tone'ом — promotional рендерятся neutral/info чтобы
* не конкурировать с major version bump'ом визуально.
*/
type TFn = (key: string, opts?: { defaultValue?: string }) => string
export function kindIcon(kind: ChangelogKind): Icon {
switch (kind) {
case 'definition_create':
return PlusCircleIcon
case 'definition_update':
return PencilSimpleIcon
case 'draft_create':
return ChatCircleTextIcon
case 'draft_review':
return EyeIcon
case 'draft_approve':
return CheckCircleIcon
case 'draft_reject':
return XCircleIcon
case 'draft_changes_requested':
return ArrowsCounterClockwiseIcon
case 'draft_publish':
return RocketIcon
case 'draft_withdraw':
return TrashIcon
}
}
export function kindBadgeVariant(kind: ChangelogKind): BadgeProps['variant'] {
switch (kind) {
case 'definition_create':
case 'draft_publish':
return 'primary'
case 'definition_update':
return 'info'
case 'draft_approve':
return 'success'
case 'draft_reject':
return 'danger'
case 'draft_changes_requested':
return 'warning'
case 'draft_create':
case 'draft_review':
case 'draft_withdraw':
return 'neutral'
}
}
export function kindLabel(kind: ChangelogKind, t: TFn): string {
const fallbackByKind: Record<ChangelogKind, string> = {
definition_create: 'Создан словарь',
definition_update: 'Изменена схема',
draft_create: 'Черновик создан',
draft_review: 'Отправлен на ревью',
draft_approve: 'Одобрен',
draft_reject: 'Отклонён',
draft_changes_requested: 'Запрошены правки',
draft_publish: 'Опубликован',
draft_withdraw: 'Отозван',
}
return t(`changelog.kind.${kind}`, { defaultValue: fallbackByKind[kind] })
}
/**
* Versioning events меняют semver/HEAD словаря. Promotional draft events
* (review/approve/reject/changes_requested/withdraw) меняют только
* workflow state — без bump'а версии.
*/
export function isVersioningKind(kind: ChangelogKind): boolean {
return (
kind === 'definition_create' ||
kind === 'definition_update' ||
kind === 'draft_publish'
)
}
+49
View File
@@ -398,6 +398,31 @@ i18n
'history.revert': 'Откатить к v{{v}}', 'history.revert': 'Откатить к v{{v}}',
'history.revertConfirm': 'Откатить к v{{v}}? Создастся новая активная версия.', 'history.revertConfirm': 'Откатить к v{{v}}? Создастся новая активная версия.',
'history.singleVersionHint': 'Это единственная версия. Кнопки «Сравнить» и «Откатить» появятся, когда у записи будет более одной версии.', 'history.singleVersionHint': 'Это единственная версия. Кнопки «Сравнить» и «Откатить» появятся, когда у записи будет более одной версии.',
// === Changelog (schema timeline + diff) ===
'changelog.empty.title': 'История пуста',
'changelog.empty.description': 'Изменения схемы и события workflow появятся здесь.',
'changelog.viewDiff': 'Сравнить',
'changelog.recordsAffected_one': '{{count}} запись',
'changelog.recordsAffected_few': '{{count}} записи',
'changelog.recordsAffected_many': '{{count}} записей',
'changelog.diff.title': 'Изменения схемы',
'changelog.diff.before': 'До',
'changelog.diff.after': 'После',
'changelog.diff.beforeEmpty': '— (первоначальное создание словаря)',
'changelog.diff.afterEmpty': '— (нет proposed schema для этого события)',
'changelog.diff.added': 'Добавлены',
'changelog.diff.removed': 'Удалены',
'changelog.diff.changed': 'Изменены',
'changelog.diff.by': 'автор',
'changelog.kind.definition_create': 'Создан словарь',
'changelog.kind.definition_update': 'Изменена схема',
'changelog.kind.draft_create': 'Черновик создан',
'changelog.kind.draft_review': 'Отправлен на ревью',
'changelog.kind.draft_approve': 'Одобрен',
'changelog.kind.draft_reject': 'Отклонён',
'changelog.kind.draft_changes_requested': 'Запрошены правки',
'changelog.kind.draft_publish': 'Опубликован',
'changelog.kind.draft_withdraw': 'Отозван',
// === Lineage (dict-relationships-v2 Phase 1) === // === Lineage (dict-relationships-v2 Phase 1) ===
'lineage.usedBy.title': 'На этот словарь ссылаются', 'lineage.usedBy.title': 'На этот словарь ссылаются',
'lineage.usedBy.count_one': '{{count}} связь', 'lineage.usedBy.count_one': '{{count}} связь',
@@ -913,6 +938,30 @@ i18n
'history.revert': 'Revert to v{{v}}', 'history.revert': 'Revert to v{{v}}',
'history.revertConfirm': 'Revert to v{{v}}? A new active version will be created.', 'history.revertConfirm': 'Revert to v{{v}}? A new active version will be created.',
'history.singleVersionHint': 'This is the only version. Compare and Revert actions will appear once the record has more than one version.', 'history.singleVersionHint': 'This is the only version. Compare and Revert actions will appear once the record has more than one version.',
// === Changelog (schema timeline + diff) ===
'changelog.empty.title': 'No history yet',
'changelog.empty.description': 'Schema changes and workflow events will appear here.',
'changelog.viewDiff': 'Compare',
'changelog.recordsAffected_one': '{{count}} record',
'changelog.recordsAffected_other': '{{count}} records',
'changelog.diff.title': 'Schema changes',
'changelog.diff.before': 'Before',
'changelog.diff.after': 'After',
'changelog.diff.beforeEmpty': '— (dictionary creation, no prior state)',
'changelog.diff.afterEmpty': '— (no proposed schema for this event)',
'changelog.diff.added': 'Added',
'changelog.diff.removed': 'Removed',
'changelog.diff.changed': 'Changed',
'changelog.diff.by': 'by',
'changelog.kind.definition_create': 'Dictionary created',
'changelog.kind.definition_update': 'Schema updated',
'changelog.kind.draft_create': 'Draft created',
'changelog.kind.draft_review': 'Sent for review',
'changelog.kind.draft_approve': 'Approved',
'changelog.kind.draft_reject': 'Rejected',
'changelog.kind.draft_changes_requested': 'Changes requested',
'changelog.kind.draft_publish': 'Published',
'changelog.kind.draft_withdraw': 'Withdrawn',
// === Lineage (dict-relationships-v2 Phase 1) === // === Lineage (dict-relationships-v2 Phase 1) ===
'lineage.usedBy.title': 'Used by', 'lineage.usedBy.title': 'Used by',
'lineage.usedBy.count_one': '{{count}} reference', 'lineage.usedBy.count_one': '{{count}} reference',
@@ -28,6 +28,7 @@ import {
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon } from '@phosphor-icons/react' import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon } from '@phosphor-icons/react'
import { import {
useAudit, useAudit,
useChangelog,
useDictionaryDetail, useDictionaryDetail,
useDictPendingDrafts, useDictPendingDrafts,
useRecordRaw, useRecordRaw,
@@ -50,6 +51,8 @@ import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel'
import { ExportModal } from '@/components/editor/ExportModal' import { ExportModal } from '@/components/editor/ExportModal'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog' import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal' import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal'
import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal'
import { kindBadgeVariant, kindIcon, kindLabel, isVersioningKind } from '@/components/changelog/kind-meta'
import { nowIsoLocal } from '@/lib/dates' import { nowIsoLocal } from '@/lib/dates'
import { recordDisplayName } from '@/lib/locales' import { recordDisplayName } from '@/lib/locales'
import { SCOPE_BORDER_TOP } from '@/lib/scope-style' import { SCOPE_BORDER_TOP } from '@/lib/scope-style'
@@ -2073,61 +2076,144 @@ function EventsTabContent({ dictName }: { dictName: string }) {
) )
} }
/** History tab — placeholder (Stage 3.x followup, требует backend changelog endpoint). */ /**
* History tab schema changelog timeline. Версионные events (создание
* словаря, schema update, draft publish) рендерятся с version badge;
* promotional draft events (review/approve/reject/changes_requested/
* withdraw) без version, neutral tone. Каждый entry имеет «Сравнить»
* который открывает {@link ChangelogDiffModal} c full before/after.
*
* <p>Backend: GET /api/v1/dictionaries/{name}/changelog ChangelogResponse.
* Diff endpoint lazy fetch'ится в modal.
*
* <p>Empty state для свежесозданных словарей без update'ов.
*/
function HistoryTabContent({ detail }: { detail?: import('@/api/client').DictionaryDetail }) { function HistoryTabContent({ detail }: { detail?: import('@/api/client').DictionaryDetail }) {
const { t } = useTranslation() const { t } = useTranslation()
const [diffEntryId, setDiffEntryId] = useState<number | undefined>(undefined)
const { data, isLoading, error } = useChangelog(detail?.name)
if (!detail) return null if (!detail) return null
// Placeholder version timeline пока backend не выкатил return (
// `/api/v1/dictionaries/{name}/versions` endpoint. Single row для текущей <div className="space-y-3">
// schema version (HEAD). Когда backend появится — рендерим full timeline. {isLoading && <LoadingBlock size="md" label={t('loading')} />}
const propsCount = Object.keys(detail.schemaJson.properties ?? {}).length {error && (
const updatedAt = new Date(detail.updatedAt) <Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)}
{data && data.entries.length === 0 && (
<EmptyState
title={t('changelog.empty.title', {
defaultValue: 'История пуста',
})}
description={t('changelog.empty.description', {
defaultValue: 'Изменения схемы и события workflow появятся здесь.',
})}
/>
)}
{data && data.entries.length > 0 && (
<ol className="relative border-l-2 border-line pl-5 space-y-3">
{data.entries.map((entry) => (
<ChangelogTimelineItem
key={entry.id}
entry={entry}
onOpenDiff={() => setDiffEntryId(entry.id)}
/>
))}
</ol>
)}
<ChangelogDiffModal
open={diffEntryId !== undefined}
onClose={() => setDiffEntryId(undefined)}
dictionaryName={detail.name}
entryId={diffEntryId}
/>
</div>
)
}
function ChangelogTimelineItem({
entry,
onOpenDiff,
}: {
entry: import('@/api/client').ChangelogEntry
onOpenDiff: () => void
}) {
const { t } = useTranslation()
const KindIcon = kindIcon(entry.kind)
const occurredAt = new Date(entry.publishedAt)
const showVersion = isVersioningKind(entry.kind) && entry.version
const added = entry.diff.added.length
const changed = entry.diff.changed.length
const removed = entry.diff.removed.length
return ( return (
<div className="space-y-2"> <li className="relative">
<div className="rounded-lg border border-line bg-surface p-4"> {/* Timeline dot marker — kind-coloured */}
<span
className={cn(
'absolute -left-[27px] top-1.5 size-3 rounded-full border-2 bg-surface',
entry.isCurrent ? 'border-accent' : 'border-line-2',
)}
aria-hidden
/>
<div className="rounded-lg border border-line bg-surface p-3">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* HEAD dot marker — filled circle */} <KindIcon weight="duotone" size={20} className="text-mute shrink-0 mt-0.5" />
<div className="shrink-0 size-3 rounded-full bg-ink mt-1.5" aria-hidden />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap"> <div className="flex items-baseline gap-2 flex-wrap">
<span className="text-mono text-title-md text-ink font-semibold"> {showVersion && (
v{detail.schemaVersion} <span className="text-mono text-title-sm text-ink font-semibold tabular-nums">
</span> v{entry.version}
<span className="text-cap font-semibold tracking-wider px-1.5 py-0.5 rounded-sm bg-ink text-on-accent uppercase">
HEAD
</span> </span>
)}
<Badge variant={kindBadgeVariant(entry.kind)}>
{kindLabel(entry.kind, t)}
</Badge>
{entry.isCurrent && (
<Badge variant="primary">
{t('history.current', { defaultValue: 'текущая' })}
</Badge>
)}
<span className="text-mono text-cell text-mute tabular-nums ml-auto"> <span className="text-mono text-cell text-mute tabular-nums ml-auto">
+{propsCount} ~0 0 {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> </span>
</div> </div>
<div className="text-body text-ink mt-1"> {entry.headers && (
{detail.description ?? t('history.placeholder.title', { <div className="text-body text-ink mt-1 truncate">{entry.headers}</div>
defaultValue: 'Текущая версия схемы', )}
<div className="flex items-center gap-3 mt-1 text-cell text-mute">
<span className="tabular-nums">{occurredAt.toLocaleString()}</span>
<span className="font-mono">{entry.publishedBy.name}</span>
{entry.recordsAffected > 0 && (
<span>
{t('changelog.recordsAffected', {
count: entry.recordsAffected,
defaultValue: `${entry.recordsAffected} запис.`,
})} })}
</span>
)}
</div> </div>
<div className="text-cell text-mute mt-0.5 tabular-nums"> <div className="mt-2">
{updatedAt.toLocaleString(undefined, { <Button
year: 'numeric', type="button"
month: '2-digit', variant="ghost"
day: '2-digit', size="sm"
hour: '2-digit', onClick={onOpenDiff}
minute: '2-digit', >
})} {t('changelog.viewDiff', { defaultValue: 'Сравнить' })}
</Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</li>
{/* Backend-pending notice */}
<div className="rounded-lg border border-dashed border-line-2 bg-surface-2/30 p-4 text-cap text-mute tracking-wider uppercase text-center">
{t('history.pending', {
defaultValue:
'Полный timeline — backend `/api/v1/dictionaries/{name}/changelog` pending',
})}
</div>
</div>
) )
} }