feat(admin-ui): Hub view (B) per design handoff — dict relations 3-col layout

This commit is contained in:
Александр Зимин
2026-05-10 18:41:37 +00:00
parent 6b71b651e6
commit 1df232679d
2 changed files with 261 additions and 4 deletions
@@ -35,6 +35,7 @@ import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
@@ -57,6 +58,9 @@ 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'
}
const SCOPE_VALUES: ReadonlySet<DataScope> = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
@@ -78,6 +82,7 @@ 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
return out
}
@@ -514,10 +519,49 @@ function DictionaryDetail() {
}
/>
{/* Phase 1 dict-relationships-v2: schema-level reverse FK card.
Hide-on-empty — большинство справочников без incoming refs не
увидят этот блок. */}
<DictionaryDependentsPanel dictionaryName={name} />
{/* View toggle: Записи (default table-based view) | Связи (Hub view с
neighbors на боках). Per design handoff B. */}
{detailQuery.data && (
<div role="group" aria-label="View mode" className="flex border border-regolith 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-label transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
active
? 'bg-orbit/40 text-ultramarain'
: 'hover:bg-orbit/20 text-carbon'
}`}
>
{t(`dict.view.${mode}`, {
defaultValue: mode === 'records' ? 'Записи' : 'Связи',
})}
</button>
)
})}
</div>
)}
{urlSearch.view === 'hub' && detailQuery.data ? (
<DictionaryHubView detail={detailQuery.data} />
) : (
/* Phase 1 dict-relationships-v2: schema-level reverse FK card.
Hide-on-empty — большинство справочников без incoming refs не
увидят этот блок. */
<DictionaryDependentsPanel dictionaryName={name} />
)}
{aoi && (
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-ultramarain/30 bg-ultramarain/4 text-sm">