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 (
-
+
-
+
- | name |
- type |
- required |
- unique |
- i18n |
- → FK |
- description |
+ # |
+ {`Поле`} |
+ {`Тип`} |
+ Required |
+ Unique |
+ I18n |
+ Index |
+ {`Описание`} |
- {entries.map(([key, prop]) => (
-
- | {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'])} |
+ {/* INDEX: backend ещё не выводит явный флаг. Используем эвристику:
+ * x-unique → автоматически индекс; x-references → FK index; иначе —. */}
+
+ {checkOrDash(prop['x-unique'] || Boolean(prop['x-references']))}
+ |
+
{prop.description ?? ''}
|
@@ -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: 'Полный лог →' })}
+
+
+
+
-
-
- | {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: 'Сообщение' })}
+ |
- {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 (
-
- |
+ |
+ |
{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',
+ })}
+
)
}