Merge branch 'feat/editor-tabs-redesign' into 'main'

feat(editor): Fields / Events / History tabs match redesign prototype

See merge request 2-6/2-6-4/terravault/ordinis!77
This commit is contained in:
Александр Зимин
2026-05-11 16:21:17 +00:00
@@ -32,6 +32,7 @@ import {
} from '@/api/queries'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey } from '@/api/mutations'
import { useCanMutate } from '@/auth/useCanMutate'
import { cn } from '@/lib/utils'
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
@@ -686,9 +687,10 @@ function DictionaryDetail() {
<EventsTabContent dictName={name} />
)}
{/* History tab — записи version timeline (стуб, Stage 3.x). */}
{/* History tab — schema versions timeline. HEAD row из current detail;
backend changelog endpoint pending для исторических versions. */}
{urlSearch.tab === 'history' && (
<HistoryTabContent />
<HistoryTabContent detail={detailQuery.data} />
)}
{/* DictionaryDependentsPanel inline-card удалён — reverse FK rows
@@ -1596,32 +1598,81 @@ function FieldsTabContent({ detail }: { detail: { schemaJson: import('@/api/clie
</div>
)
}
// Render type per prototype: для FK → `fk → target_dict`. Для array → `string[]`.
// Иначе native JSON-schema type.
const renderType = (prop: import('@/api/client').JsonSchema): React.ReactNode => {
const ref = prop['x-references']
if (typeof ref === 'string' && ref.length > 0) {
const [target] = ref.split('.')
return (
<span className="text-mono text-ink-2">
fk <span className="text-mute"></span>{' '}
<span className="text-accent">{target}</span>
</span>
)
}
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
return <span className="text-mono text-ink-2">enum</span>
}
if (prop.type === 'array' && prop.items) {
const itemT = typeof prop.items === 'object' ? prop.items.type ?? '—' : '—'
return <span className="text-mono text-ink-2">{itemT}[]</span>
}
if (prop.format === 'date' || prop.format === 'date-time') {
return <span className="text-mono text-ink-2">{prop.format}</span>
}
return <span className="text-mono text-ink-2">{prop.type ?? '—'}</span>
}
const checkOrDash = (v: unknown) =>
v ? (
<span className="text-green" aria-label="yes">
</span>
) : (
<span className="text-mute/50" aria-hidden>
</span>
)
return (
<div className="rounded-lg border border-line overflow-hidden">
<div className="rounded-lg border border-line bg-surface overflow-hidden">
<table className="w-full text-body">
<thead className="text-cap bg-surface-2 font-display text-mute">
<thead className="text-cap font-display text-mute tracking-[0.14em] uppercase">
<tr>
<th className="text-left px-3 py-2">name</th>
<th className="text-left px-3 py-2">type</th>
<th className="text-left px-3 py-2">required</th>
<th className="text-left px-3 py-2">unique</th>
<th className="text-left px-3 py-2">i18n</th>
<th className="text-left px-3 py-2"> FK</th>
<th className="text-left px-3 py-2">description</th>
<th className="text-left px-3 py-2.5 w-12 font-mono text-mute/70 normal-case">#</th>
<th className="text-left px-3 py-2.5">{`Поле`}</th>
<th className="text-left px-3 py-2.5 w-[160px]">{`Тип`}</th>
<th className="text-center px-3 py-2.5 w-20">Required</th>
<th className="text-center px-3 py-2.5 w-20">Unique</th>
<th className="text-center px-3 py-2.5 w-20">I18n</th>
<th className="text-center px-3 py-2.5 w-20">Index</th>
<th className="text-left px-3 py-2.5">{`Описание`}</th>
</tr>
</thead>
<tbody>
{entries.map(([key, prop]) => (
<tr key={key} className="border-t border-line-2 hover:bg-surface-2/50">
<td className="text-cell px-3 py-1.5 font-mono">{key}</td>
<td className="px-3 py-1.5 text-ink-2">{prop.type ?? '—'}</td>
<td className="px-3 py-1.5">{required.has(key) ? '✓' : ''}</td>
<td className="px-3 py-1.5">{prop['x-unique'] ? '✓' : ''}</td>
<td className="px-3 py-1.5">{prop['x-localized'] ? '✓' : ''}</td>
<td className="text-mono px-3 py-1.5 text-accent">
{prop['x-references'] ?? ''}
{entries.map(([key, prop], idx) => (
<tr
key={key}
className="border-t border-line-2 hover:bg-surface-2/40 transition-colors"
>
<td className="px-3 py-2 text-mono text-mute tabular-nums">
{String(idx + 1).padStart(2, '0')}
</td>
<td className="px-3 py-1.5 text-ink-2 text-cell max-w-[300px] truncate">
<td className="px-3 py-2 font-mono font-semibold text-accent">
{key}
</td>
<td className="px-3 py-2">{renderType(prop)}</td>
<td className="px-3 py-2 text-center">{checkOrDash(required.has(key))}</td>
<td className="px-3 py-2 text-center">{checkOrDash(prop['x-unique'])}</td>
<td className="px-3 py-2 text-center">{checkOrDash(prop['x-localized'])}</td>
{/* INDEX: backend ещё не выводит явный флаг. Используем эвристику:
* x-unique → автоматически индекс; x-references → FK index; иначе —. */}
<td className="px-3 py-2 text-center">
{checkOrDash(prop['x-unique'] || Boolean(prop['x-references']))}
</td>
<td className="px-3 py-2 text-ink-2 text-cell max-w-[320px] truncate">
{prop.description ?? ''}
</td>
</tr>
@@ -1706,7 +1757,11 @@ function highlightJson(json: string): string {
*/
function EventsTabContent({ dictName }: { dictName: string }) {
const { t } = useTranslation()
const { data, isLoading, error } = useAudit({ dictionaryName: dictName, size: 20 })
const { data, isLoading, error } = useAudit({ dictionaryName: dictName, size: 50 })
// Filter chips per prototype: все / edit / publish / review / webhook / export.
// Backend audit actions: CREATE / UPDATE / CLOSE / EXPORT etc. — map to display
// groups for chip filter.
const [filter, setFilter] = useState<'all' | 'edit' | 'publish' | 'review' | 'webhook' | 'export'>('all')
if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
if (error)
@@ -1726,69 +1781,233 @@ function EventsTabContent({ dictName }: { dictName: string }) {
/>
)
/** Group audit action в chip filter category. */
const eventCategory = (action: string): 'edit' | 'publish' | 'review' | 'webhook' | 'export' => {
const a = action.toUpperCase()
if (a === 'CREATE' || a === 'UPDATE' || a === 'CLOSE') return 'edit'
if (a === 'EXPORT') return 'export'
if (a === 'PUBLISH') return 'publish'
if (a === 'REVIEW' || a === 'APPROVE' || a === 'DRAFT') return 'review'
if (a === 'WEBHOOK') return 'webhook'
return 'edit'
}
/** Counts per category для chip labels. */
const counts = {
all: rows.length,
edit: rows.filter((r) => eventCategory(r.action) === 'edit').length,
publish: rows.filter((r) => eventCategory(r.action) === 'publish').length,
review: rows.filter((r) => eventCategory(r.action) === 'review').length,
webhook: rows.filter((r) => eventCategory(r.action) === 'webhook').length,
export: rows.filter((r) => eventCategory(r.action) === 'export').length,
}
const visible = filter === 'all' ? rows : rows.filter((r) => eventCategory(r.action) === filter)
// Color chip per event type — соответствует prototype.
const typeChipClass = (cat: 'edit' | 'publish' | 'review' | 'webhook' | 'export'): string => {
switch (cat) {
case 'webhook':
return 'bg-accent-bg text-accent'
case 'publish':
return 'bg-green-bg text-green'
case 'review':
return 'bg-warn-bg text-warn'
case 'export':
return 'bg-surface-2 text-ink-2'
case 'edit':
default:
return 'bg-accent-bg/50 text-accent'
}
}
const FILTER_CHIPS: { id: typeof filter; label: string; count: number }[] = [
{ id: 'all', label: t('events.filter.all', { defaultValue: 'все' }), count: counts.all },
{ id: 'webhook', label: 'webhooks', count: counts.webhook },
{ id: 'publish', label: 'publish', count: counts.publish },
{ id: 'review', label: 'review', count: counts.review },
{ id: 'edit', label: 'edit', count: counts.edit },
{ id: 'export', label: 'export', count: counts.export },
]
return (
<div className="space-y-3">
<div className="rounded-lg border border-line overflow-hidden">
{/* Filter chips per prototype line 07-editor-events */}
<div className="flex items-center gap-1.5 flex-wrap">
{FILTER_CHIPS.filter((c) => c.id === 'all' || c.count > 0).map((c) => {
const active = filter === c.id
return (
<button
key={c.id}
type="button"
onClick={() => setFilter(c.id)}
aria-pressed={active}
className={cn(
'h-7 px-3 rounded-md text-body transition-colors inline-flex items-center gap-1.5',
active
? 'bg-navy text-on-accent font-semibold'
: 'border border-line bg-surface text-ink-2 hover:bg-surface-2',
)}
>
{c.label}
<span className={cn('text-mono', active ? 'opacity-90' : 'text-mute')}>
{c.count}
</span>
</button>
)
})}
<Link
to="/audit"
search={{ dict: dictName }}
className="ml-auto h-7 px-3 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 text-body inline-flex items-center transition-colors"
>
{t('events.viewAll.short', { defaultValue: 'Полный лог →' })}
</Link>
</div>
<div className="rounded-lg border border-line bg-surface overflow-hidden">
<table className="w-full">
<thead className="bg-surface-2">
<tr>
<th className="text-cap text-mute text-left px-3 py-2">{t('audit.col.time', { defaultValue: 'time' })}</th>
<th className="text-cap text-mute text-left px-3 py-2">{t('audit.col.action', { defaultValue: 'action' })}</th>
<th className="text-cap text-mute text-left px-3 py-2">{t('audit.col.businessKey', { defaultValue: 'record' })}</th>
<th className="text-cap text-mute text-left px-3 py-2">{t('audit.col.user', { defaultValue: 'user' })}</th>
<thead>
<tr className="text-cap text-mute tracking-[0.14em] uppercase font-display border-b border-line-2">
<th className="text-left px-3 py-2 w-[140px]">
{t('audit.col.time', { defaultValue: 'Время' })}
</th>
<th className="text-left px-3 py-2 w-24">
{t('audit.col.action', { defaultValue: 'Тип' })}
</th>
<th className="text-left px-3 py-2 w-20">
{t('audit.col.status', { defaultValue: 'Статус' })}
</th>
<th className="text-left px-3 py-2 w-[180px]">
{t('audit.col.user', { defaultValue: 'Куда · Кто' })}
</th>
<th className="text-left px-3 py-2">
{t('audit.col.businessKey', { defaultValue: 'Сообщение' })}
</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
{visible.map((r) => {
const time = new Date(r.eventTime)
const variant =
r.action === 'CREATE'
? ('success' as const)
: r.action === 'CLOSE'
? ('error' as const)
: ('info' as const)
const cat = eventCategory(r.action)
// Backend audit currently не выставляет explicit status field;
// treat CREATE/UPDATE/CLOSE/EXPORT как 'ok'. Future: parse error
// logs для 'fail' state.
const status: 'ok' | 'fail' = 'ok'
return (
<tr key={`${r.id}`} className="border-t border-line-2 hover:bg-surface-2/40">
<td className="text-cell tabular-nums px-3 py-1.5">
<tr
key={`${r.id}`}
className="border-t border-line-2 hover:bg-surface-2/40 transition-colors"
>
<td className="text-mono text-cell tabular-nums px-3 py-2 text-ink-2">
{time.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</td>
<td className="px-3 py-1.5">
<Badge variant={variant}>
{t(`audit.action.${r.action}`, r.action)}
</Badge>
<td className="px-3 py-2">
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-sm text-cap tracking-wider font-semibold uppercase',
typeChipClass(cat),
)}
>
{cat}
</span>
</td>
<td className="px-3 py-2">
<span className="inline-flex items-center gap-1.5 text-cell text-ink-2">
<span
className={cn(
'size-1.5 rounded-full',
status === 'ok' ? 'bg-green' : 'bg-pink',
)}
aria-hidden
/>
{status}
</span>
</td>
<td className="px-3 py-2 text-mono text-cell text-ink-2">
{r.userId ?? 'anonymous'}
</td>
<td className="px-3 py-2 text-cell text-ink-2 max-w-md truncate">
{r.businessKey ? (
<span>
<span className="text-mono text-accent">{r.businessKey}</span>
{' · '}
<span className="text-mute">{r.action}</span>
</span>
) : (
<span className="text-mute">{r.action}</span>
)}
</td>
<td className="text-mono px-3 py-1.5">{r.businessKey ?? '—'}</td>
<td className="text-cell px-3 py-1.5 text-ink-2">{r.userId ?? 'anonymous'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="text-right">
<Link
to="/audit"
search={{ dict: dictName }}
className="inline-flex items-center gap-1 text-accent hover:underline text-body"
>
{t('events.viewAll', { defaultValue: 'Полный аудит-лог →' })}
</Link>
</div>
</div>
)
}
/** History tab — placeholder (Stage 3.x followup, требует backend changelog endpoint). */
function HistoryTabContent() {
function HistoryTabContent({ detail }: { detail?: import('@/api/client').DictionaryDetail }) {
const { t } = useTranslation()
if (!detail) return null
// Placeholder version timeline пока backend не выкатил
// `/api/v1/dictionaries/{name}/versions` endpoint. Single row для текущей
// schema version (HEAD). Когда backend появится — рендерим full timeline.
const propsCount = Object.keys(detail.schemaJson.properties ?? {}).length
const updatedAt = new Date(detail.updatedAt)
return (
<div className="rounded-lg border border-dashed border-line bg-surface-2/30 p-8 text-center text-body text-mute">
Timeline изменений справочника скоро. Требует backend endpoint для schema versions.
<div className="space-y-2">
<div className="rounded-lg border border-line bg-surface p-4">
<div className="flex items-start gap-3">
{/* HEAD dot marker — filled circle */}
<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 items-baseline gap-2 flex-wrap">
<span className="text-mono text-title-md text-ink font-semibold">
v{detail.schemaVersion}
</span>
<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 className="text-mono text-cell text-mute tabular-nums ml-auto">
+{propsCount} ~0 0
</span>
</div>
<div className="text-body text-ink mt-1">
{detail.description ?? t('history.placeholder.title', {
defaultValue: 'Текущая версия схемы',
})}
</div>
<div className="text-cell text-mute mt-0.5 tabular-nums">
{updatedAt.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
</div>
</div>
{/* 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>
)
}