feat(admin-ui): editor 6-tab layout (Stage 3.4)
This commit is contained in:
@@ -53,7 +53,7 @@ const NeighborCard = ({
|
|||||||
<Link
|
<Link
|
||||||
to="/dictionaries/$name"
|
to="/dictionaries/$name"
|
||||||
params={{ name: dict.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 ${
|
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'
|
side === 'left' ? 'text-right' : 'text-left'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -60,9 +60,15 @@ type DictSearch = {
|
|||||||
/** Time-travel: ISO datetime "просмотр на момент X". Если задан — backend
|
/** Time-travel: ISO datetime "просмотр на момент X". Если задан — backend
|
||||||
* findActiveAt(at) возвращает active version на этот момент вместо now(). */
|
* findActiveAt(at) возвращает active version на этот момент вместо now(). */
|
||||||
at?: string
|
at?: string
|
||||||
/** View mode: 'records' (default — table + filters) | 'hub' (relations graph
|
/** Active editor tab (Stage 3.4 handoff design):
|
||||||
* с current dict в центре + neighbors на боках). См. DictionaryHubView. */
|
* - records (default): table + filters + edit modal
|
||||||
view?: 'records' | 'hub'
|
* - 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'])
|
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))) {
|
if (typeof raw.at === 'string' && !isNaN(Date.parse(raw.at))) {
|
||||||
out.at = 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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,47 +580,52 @@ function DictionaryDetail() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* View toggle: Записи (default table-based view) | Связи (Hub view с
|
{/* Editor tabs per handoff Stage 3.4:
|
||||||
neighbors на боках). Per design handoff B. */}
|
Records | Relations | Fields | JSON | Events | History.
|
||||||
|
URL-synced через ?tab=. Default — records (records table + filters). */}
|
||||||
{detailQuery.data && (
|
{detailQuery.data && (
|
||||||
<div role="group" aria-label="View mode" className="flex border border-line rounded-sm overflow-hidden w-fit">
|
<EditorTabBar
|
||||||
{(['records', 'hub'] as const).map((mode) => {
|
active={urlSearch.tab ?? 'records'}
|
||||||
const active = (urlSearch.view ?? 'records') === mode
|
onChange={(tab) =>
|
||||||
return (
|
void navigate({
|
||||||
<button
|
search: (prev) => ({
|
||||||
key={mode}
|
...prev,
|
||||||
type="button"
|
tab: tab === 'records' ? undefined : tab,
|
||||||
onClick={() =>
|
}),
|
||||||
void navigate({
|
replace: true,
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{urlSearch.view === 'hub' && detailQuery.data ? (
|
{/* Relations tab — incoming FK + outgoing schema FK. */}
|
||||||
|
{urlSearch.tab === 'relations' && detailQuery.data && (
|
||||||
<DictionaryHubView detail={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} />
|
<DictionaryDependentsPanel dictionaryName={name} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -629,6 +648,10 @@ function DictionaryDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* === Records tab content (default, also fallback if no ?tab) === */}
|
||||||
|
{(!urlSearch.tab || urlSearch.tab === 'records') && (
|
||||||
|
<>
|
||||||
|
|
||||||
{/* Time-travel picker — раскрывается по клику на toolbar button.
|
{/* Time-travel picker — раскрывается по клику на toolbar button.
|
||||||
ISO datetime в URL ?at=… — share-friendly. Active state показывает
|
ISO datetime в URL ?at=… — share-friendly. Active state показывает
|
||||||
ambient banner справа от input'а. Phase v1 stretch. */}
|
ambient banner справа от input'а. Phase v1 stretch. */}
|
||||||
@@ -870,6 +893,10 @@ function DictionaryDetail() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* === end records tab content === */}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={edit.kind === 'create' || edit.kind === 'edit'}
|
isOpen={edit.kind === 'create' || edit.kind === 'edit'}
|
||||||
onClose={() => setEdit({ kind: 'closed' })}
|
onClose={() => setEdit({ kind: 'closed' })}
|
||||||
@@ -1302,3 +1329,133 @@ const serverErrorMessage = (err: unknown): string | null => {
|
|||||||
}
|
}
|
||||||
return err instanceof Error ? err.message : 'Unknown error'
|
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