feat(admin-ui): editor 6-tab layout (Stage 3.4)
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user