feat(editor): Fields / Events / History tabs match redesign prototype
Per redesign/screens/{05-fields,07-events,08-history}.png:
Fields tab (Поля):
- # numbered col (01, 02, ...) — mono mute, tabular-nums
- ПОЛЕ — bold accent (was plain mono)
- ТИП — inline FK render: `fk → target_dict`, array as `string[]`,
date/date-time format names, enum, native types
- REQUIRED / UNIQUE / I18N / INDEX — checkmark (green) or — dash
- INDEX heuristic: x-unique || x-references → indexed (backend doesn't
expose explicit flag yet)
- ОПИСАНИЕ truncate с max-w-[320px]
Events tab (События):
- Filter chips above table: все N / webhooks / publish / review / edit /
export (только показываем те которые есть, кроме «все»)
- "Полный лог →" link справа в chips row
- Table cols: Время (5 sec precision) / Тип (colored chip) / Статус
(● dot + ok/fail) / Куда·Кто (user) / Сообщение (businessKey · action)
- Type chips colored: webhook=accent, publish=green, review=warn,
export=surface-2, edit=accent-bg/50
History tab (История):
- HEAD row: ● filled dot + v{version} + HEAD chip (dark) + change summary
(+N ~0 −0 placeholder until backend changelog endpoint появится)
- Description + updatedAt date
- Backend-pending notice card: "/api/v1/dictionaries/{name}/changelog pending"
Plus: + import { cn } from '@/lib/utils' для новых tabs
This commit is contained in:
@@ -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 (
|
||||
<div className="rounded-lg border border-line overflow-hidden">
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user