diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 39da0c8..1ac31ab 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -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() { )} - {/* History tab — записи version timeline (стуб, Stage 3.x). */} + {/* History tab — schema versions timeline. HEAD row из current detail; + backend changelog endpoint pending для исторических versions. */} {urlSearch.tab === 'history' && ( - + )} {/* DictionaryDependentsPanel inline-card удалён — reverse FK rows @@ -1596,32 +1598,81 @@ function FieldsTabContent({ detail }: { detail: { schemaJson: import('@/api/clie ) } + + // 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 ( + + fk {' '} + {target} + + ) + } + if (Array.isArray(prop.enum) && prop.enum.length > 0) { + return enum + } + if (prop.type === 'array' && prop.items) { + const itemT = typeof prop.items === 'object' ? prop.items.type ?? '—' : '—' + return {itemT}[] + } + if (prop.format === 'date' || prop.format === 'date-time') { + return {prop.format} + } + return {prop.type ?? '—'} + } + + const checkOrDash = (v: unknown) => + v ? ( + + ✓ + + ) : ( + + — + + ) + return ( -
+
- + - - - - - - - + + + + + + + + - {entries.map(([key, prop]) => ( - - - - - - - + - + + + + + {/* INDEX: backend ещё не выводит явный флаг. Используем эвристику: + * x-unique → автоматически индекс; x-references → FK index; иначе —. */} + + @@ -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 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 (
-
+ {/* Filter chips per prototype line 07-editor-events */} +
+ {FILTER_CHIPS.filter((c) => c.id === 'all' || c.count > 0).map((c) => { + const active = filter === c.id + return ( + + ) + })} + + {t('events.viewAll.short', { defaultValue: 'Полный лог →' })} + +
+ +
nametyperequireduniquei18n→ FKdescription#{`Поле`}{`Тип`}RequiredUniqueI18nIndex{`Описание`}
{key}{prop.type ?? '—'}{required.has(key) ? '✓' : ''}{prop['x-unique'] ? '✓' : ''}{prop['x-localized'] ? '✓' : ''} - {prop['x-references'] ?? ''} + {entries.map(([key, prop], idx) => ( +
+ {String(idx + 1).padStart(2, '0')} + + {key} + {renderType(prop)}{checkOrDash(required.has(key))}{checkOrDash(prop['x-unique'])}{checkOrDash(prop['x-localized'])} + {checkOrDash(prop['x-unique'] || Boolean(prop['x-references']))} + {prop.description ?? ''}
- - - - - - + + + + + + + - {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 ( - - + - + + + - - ) })}
{t('audit.col.time', { defaultValue: 'time' })}{t('audit.col.action', { defaultValue: 'action' })}{t('audit.col.businessKey', { defaultValue: 'record' })}{t('audit.col.user', { defaultValue: 'user' })}
+ {t('audit.col.time', { defaultValue: 'Время' })} + + {t('audit.col.action', { defaultValue: 'Тип' })} + + {t('audit.col.status', { defaultValue: 'Статус' })} + + {t('audit.col.user', { defaultValue: 'Куда · Кто' })} + + {t('audit.col.businessKey', { defaultValue: 'Сообщение' })} +
+
{time.toLocaleString(undefined, { - year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', + second: '2-digit', })} - - {t(`audit.action.${r.action}`, r.action)} - + + + {cat} + + + + + {status} + + + {r.userId ?? 'anonymous'} + + {r.businessKey ? ( + + {r.businessKey} + {' · '} + {r.action} + + ) : ( + {r.action} + )} {r.businessKey ?? '—'}{r.userId ?? 'anonymous'}
-
- - {t('events.viewAll', { defaultValue: 'Полный аудит-лог →' })} - -
) } /** 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 ( -
- Timeline изменений справочника — скоро. Требует backend endpoint для schema versions. +
+
+
+ {/* HEAD dot marker — filled circle */} +
+
+
+ + v{detail.schemaVersion} + + + HEAD + + + +{propsCount} ~0 −0 + +
+
+ {detail.description ?? t('history.placeholder.title', { + defaultValue: 'Текущая версия схемы', + })} +
+
+ {updatedAt.toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} +
+
+
+
+ + {/* Backend-pending notice */} +
+ {t('history.pending', { + defaultValue: + 'Полный timeline — backend `/api/v1/dictionaries/{name}/changelog` pending', + })} +
) }