feat(admin-ui): editor 6-tab layout (Stage 3.4)

Dictionary editor route теперь имеет TabBar (handoff design Stage 3.4):
  Records | Relations | Fields | JSON | Events | History

URL-synced через ?tab= (default: records). Backward-compat: старый
?view=hub auto-remap'нется в ?tab=relations при mount'е.

Tab contents:
- records (default): существующий records table + filters + edit modal
  flow — без изменений, обёрнут в conditional render
- relations: DictionaryHubView (был раньше при view=hub)
- fields: schema properties summary — name/type/required/unique/i18n/FK/desc
- json: pretty-printed schemaJson read-only
- events: link к /audit с pre-filled dict filter
- history: placeholder (schema version timeline — backend endpoint pending)

EditorTabBar — inline component с handoff styling (border-b border-line +
active accent border + font-semibold), -mb-px overlap для clean look.

Hub view's neighbor cards теперь search={tab:'relations'} вместо
view='hub' (consistent с new param).

DictionaryDependentsPanel рендерится только в Records tab — auto Hub view
в Relations tab уже включает dependents через свой layout.

Tests: 116/116 PASS, build green, TS clean.
This commit is contained in:
Zimin A.N.
2026-05-11 13:54:26 +03:00
parent 5ffa8d3c86
commit 0ddba871f7
2 changed files with 199 additions and 42 deletions
@@ -53,7 +53,7 @@ const NeighborCard = ({
<Link
to="/dictionaries/$name"
params={{ name: dict.name }}
search={{ view: 'hub' }}
search={{ tab: 'relations' }}
className={`group block bg-white border border-line rounded-lg p-3 transition hover:shadow-hover hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
side === 'left' ? 'text-right' : 'text-left'
}`}
@@ -60,9 +60,15 @@ type DictSearch = {
/** Time-travel: ISO datetime "просмотр на момент X". Если задан — backend
* findActiveAt(at) возвращает active version на этот момент вместо now(). */
at?: string
/** View mode: 'records' (default — table + filters) | 'hub' (relations graph
* с current dict в центре + neighbors на боках). См. DictionaryHubView. */
view?: 'records' | 'hub'
/** Active editor tab (Stage 3.4 handoff design):
* - records (default): table + filters + edit modal
* - relations: dependents graph (incoming FK refs + outgoing schema FKs)
* - fields: schema properties grid
* - json: raw schema JSON viewer
* - events: audit log filtered к этому dict
* - history: timeline записей changes
* Backward-compat: старый ?view=hub аутенитично remap'нется в ?tab=relations. */
tab?: 'records' | 'relations' | 'fields' | 'json' | 'events' | 'history'
}
const SCOPE_VALUES: ReadonlySet<DataScope> = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
@@ -84,7 +90,15 @@ const validateSearch = (raw: Record<string, unknown>): DictSearch => {
if (typeof raw.at === 'string' && !isNaN(Date.parse(raw.at))) {
out.at = raw.at
}
if (raw.view === 'hub' || raw.view === 'records') out.view = raw.view
// Tab param + backward-compat для старого ?view=hub.
const TABS: ReadonlySet<DictSearch['tab']> = new Set([
'records', 'relations', 'fields', 'json', 'events', 'history',
])
if (typeof raw.tab === 'string' && TABS.has(raw.tab as DictSearch['tab'])) {
out.tab = raw.tab as DictSearch['tab']
} else if (raw.view === 'hub') {
out.tab = 'relations'
}
return out
}
@@ -566,47 +580,52 @@ function DictionaryDetail() {
}
/>
{/* View toggle: Записи (default table-based view) | Связи (Hub view с
neighbors на боках). Per design handoff B. */}
{/* Editor tabs per handoff Stage 3.4:
Records | Relations | Fields | JSON | Events | History.
URL-synced через ?tab=. Default — records (records table + filters). */}
{detailQuery.data && (
<div role="group" aria-label="View mode" className="flex border border-line rounded-sm overflow-hidden w-fit">
{(['records', 'hub'] as const).map((mode) => {
const active = (urlSearch.view ?? 'records') === mode
return (
<button
key={mode}
type="button"
onClick={() =>
void navigate({
search: (prev) => ({
...prev,
view: mode === 'records' ? undefined : mode,
}),
replace: true,
})
}
aria-pressed={active}
className={`px-3 py-1 text-2xs uppercase tracking-[0.10em] transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
active
? 'bg-accent-bg text-accent'
: 'hover:bg-accent-bg/20 text-ink'
}`}
>
{t(`dict.view.${mode}`, {
defaultValue: mode === 'records' ? 'Записи' : 'Связи',
})}
</button>
)
})}
</div>
<EditorTabBar
active={urlSearch.tab ?? 'records'}
onChange={(tab) =>
void navigate({
search: (prev) => ({
...prev,
tab: tab === 'records' ? undefined : tab,
}),
replace: true,
})
}
/>
)}
{urlSearch.view === 'hub' && detailQuery.data ? (
{/* Relations tab — incoming FK + outgoing schema FK. */}
{urlSearch.tab === 'relations' && detailQuery.data && (
<DictionaryHubView detail={detailQuery.data} />
) : (
/* Phase 1 dict-relationships-v2: schema-level reverse FK card.
Hide-on-empty — большинство справочников без incoming refs не
увидят этот блок. */
)}
{/* Fields tab — schema properties grid. */}
{urlSearch.tab === 'fields' && detailQuery.data && (
<FieldsTabContent detail={detailQuery.data} />
)}
{/* JSON tab — raw schema viewer (read-only). */}
{urlSearch.tab === 'json' && detailQuery.data && (
<JsonTabContent detail={detailQuery.data} />
)}
{/* Events tab — link to /audit с pre-filled dict filter. */}
{urlSearch.tab === 'events' && (
<EventsTabContent dictName={name} />
)}
{/* History tab — записи version timeline (стуб, Stage 3.x). */}
{urlSearch.tab === 'history' && (
<HistoryTabContent />
)}
{/* DictionaryDependentsPanel — отображается ТОЛЬКО на Records tab.
Phase 1 dict-relationships-v2 reverse FK card. Hide-on-empty. */}
{(!urlSearch.tab || urlSearch.tab === 'records') && (
<DictionaryDependentsPanel dictionaryName={name} />
)}
@@ -629,6 +648,10 @@ function DictionaryDetail() {
</div>
)}
{/* === Records tab content (default, also fallback if no ?tab) === */}
{(!urlSearch.tab || urlSearch.tab === 'records') && (
<>
{/* Time-travel picker — раскрывается по клику на toolbar button.
ISO datetime в URL ?at=… — share-friendly. Active state показывает
ambient banner справа от input'а. Phase v1 stretch. */}
@@ -870,6 +893,10 @@ function DictionaryDetail() {
</>
)}
</>
)}
{/* === end records tab content === */}
<Modal
isOpen={edit.kind === 'create' || edit.kind === 'edit'}
onClose={() => setEdit({ kind: 'closed' })}
@@ -1302,3 +1329,133 @@ const serverErrorMessage = (err: unknown): string | null => {
}
return err instanceof Error ? err.message : 'Unknown error'
}
// ===== Editor Tabs (Stage 3.4) =====
type EditorTab = 'records' | 'relations' | 'fields' | 'json' | 'events' | 'history'
function EditorTabBar({
active,
onChange,
}: {
active: EditorTab
onChange: (tab: EditorTab) => void
}) {
const { t } = useTranslation()
const tabs: { id: EditorTab; label: string }[] = [
{ id: 'records', label: t('editor.tab.records', { defaultValue: 'Записи' }) },
{ id: 'relations', label: t('editor.tab.relations', { defaultValue: 'Связи' }) },
{ id: 'fields', label: t('editor.tab.fields', { defaultValue: 'Поля' }) },
{ id: 'json', label: t('editor.tab.json', { defaultValue: 'JSON' }) },
{ id: 'events', label: t('editor.tab.events', { defaultValue: 'События' }) },
{ id: 'history', label: t('editor.tab.history', { defaultValue: 'История' }) },
]
return (
<div className="flex items-center gap-0 border-b border-line w-full">
{tabs.map((tab) => {
const isActive = active === tab.id
return (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
aria-pressed={isActive}
className={[
'px-3.5 h-10 text-sm transition-colors -mb-px border-b-2',
isActive
? 'border-accent text-ink font-semibold'
: 'border-transparent text-ink-2 hover:text-ink',
].join(' ')}
>
{tab.label}
</button>
)
})}
</div>
)
}
/** Fields tab — schema properties summary. */
function FieldsTabContent({ detail }: { detail: { schemaJson: import('@/api/client').JsonSchema } }) {
const props = detail.schemaJson.properties ?? {}
const required = new Set(detail.schemaJson.required ?? [])
const entries = Object.entries(props)
if (entries.length === 0) {
return (
<div className="text-sm text-mute py-8 text-center">
Schema без properties.
</div>
)
}
return (
<div className="rounded-lg border border-line overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-surface-2 text-2xs font-display uppercase tracking-[0.10em] text-mute">
<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>
</tr>
</thead>
<tbody>
{entries.map(([key, prop]) => (
<tr key={key} className="border-t border-line-2 hover:bg-surface-2/50">
<td className="px-3 py-1.5 font-mono text-[12.5px]">{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="px-3 py-1.5 font-mono text-[11px] text-accent">
{prop['x-references'] ?? ''}
</td>
<td className="px-3 py-1.5 text-ink-2 text-xs max-w-[300px] truncate">
{prop.description ?? ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
/** JSON tab — raw schema JSON, pretty-printed read-only. */
function JsonTabContent({ detail }: { detail: { schemaJson: import('@/api/client').JsonSchema } }) {
const json = JSON.stringify(detail.schemaJson, null, 2)
return (
<pre className="rounded-lg border border-line bg-surface-2 p-4 overflow-x-auto text-[12px] font-mono text-ink whitespace-pre">
{json}
</pre>
)
}
/** Events tab — pointer to audit log scoped to this dict. */
function EventsTabContent({ dictName }: { dictName: string }) {
return (
<div className="rounded-lg border border-line bg-surface p-6 text-center space-y-3">
<p className="text-sm text-ink-2">
События для справочника <span className="font-mono">{dictName}</span> в общем аудит-логе.
</p>
<Link
to="/audit"
search={{ dict: dictName }}
className="inline-flex items-center gap-1 text-accent hover:underline text-sm"
>
Открыть /audit с фильтром
</Link>
</div>
)
}
/** History tab — placeholder (Stage 3.x followup, требует backend changelog endpoint). */
function HistoryTabContent() {
return (
<div className="rounded-lg border border-dashed border-line bg-surface-2/30 p-8 text-center text-sm text-mute">
Timeline изменений справочника скоро. Требует backend endpoint для schema versions.
</div>
)
}