From 1df232679d02b0a2acb9e5809ffc18c10a06eb8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Sun, 10 May 2026 18:41:37 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin-ui):=20Hub=20view=20(B)=20per=20desi?= =?UTF-8?q?gn=20handoff=20=E2=80=94=20dict=20relations=203-col=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/lineage/DictionaryHubView.tsx | 213 ++++++++++++++++++ .../src/routes/dictionaries.$name.tsx | 52 ++++- 2 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx diff --git a/ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx b/ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx new file mode 100644 index 0000000..3cb8810 --- /dev/null +++ b/ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx @@ -0,0 +1,213 @@ +/** + * Hub view (View B из design handoff) — focused dictionary в центре + соседи + * по обе стороны. + * + * Layout: + * ┌──────────────┬─────────────────┬──────────────┐ + * │ → ссылается │ │ ← N использ. │ + * │ (outgoing) │ (focused) │ (refBy) │ + * └──────────────┴─────────────────┴──────────────┘ + * + * Outgoing FK derived from schemaJson properties[*].x-references + * (format: "dict.field"). Incoming refBy via useDictionaryDependents. + * + * Click на neighbor card → navigate к его Hub view (recursion fine). + */ +import { useMemo } from 'react' +import { Link } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { Badge } from '@nstart/ui' +import { useDictionaryDependents, useDictionaries } from '@/api/queries' +import type { DictionaryDetail, DictionaryDefinition, JsonSchema } from '@/api/client' + +const SCOPE_STRIP: Record = { + PUBLIC: 'bg-emerald-500', + INTERNAL: 'bg-amber-500', + RESTRICTED: 'bg-rose-500', +} + +/** + * Walk schemaJson properties — собираем targets из x-references. + * Format: "targetDict.targetField" → возвращаем уникальные targetDict names. + */ +const extractOutgoingFk = (schema: JsonSchema | undefined): string[] => { + if (!schema?.properties) return [] + const targets = new Set() + for (const prop of Object.values(schema.properties)) { + const ref = prop['x-references'] + if (typeof ref === 'string' && ref.length > 0) { + const [target] = ref.split('.') + if (target) targets.add(target) + } + } + return Array.from(targets).sort() +} + +const NeighborCard = ({ + dict, + side, +}: { + dict: DictionaryDefinition + side: 'left' | 'right' +}) => ( + +
+
+
+ {dict.name} +
+ +) + +const SectionLabel = ({ + side, + count, +}: { + side: 'left' | 'right' + count: number +}) => { + const { t } = useTranslation() + return ( +
+ {side === 'left' + ? t('hub.outgoing', { count, defaultValue: '→ ссылается ({{count}})' }) + : t('hub.incoming', { count, defaultValue: '← {{count}} использ.' })} +
+ ) +} + +export function DictionaryHubView({ detail }: { detail: DictionaryDetail }) { + const { t } = useTranslation() + const { data: allDicts } = useDictionaries() + const { data: refByRaw } = useDictionaryDependents(detail.name) + + // Outgoing FK targets — names derived из schemaJson. + const outgoingNames = useMemo( + () => extractOutgoingFk(detail.schemaJson), + [detail.schemaJson], + ) + + // Resolve names → DictionaryDefinition (для card rendering). + // Filter недоступные / unknown names (защита от устаревших schema refs). + const outgoingDicts = useMemo(() => { + if (!allDicts) return [] + const byName = new Map(allDicts.map((d) => [d.name, d])) + return outgoingNames + .map((name) => byName.get(name)) + .filter((d): d is DictionaryDefinition => Boolean(d)) + }, [allDicts, outgoingNames]) + + // refBy: уникальные source dicts (те что ссылаются на нас) → resolve в definitions. + const incomingDicts = useMemo(() => { + if (!allDicts || !refByRaw) return [] + const byName = new Map(allDicts.map((d) => [d.name, d])) + const seen = new Set() + const out: DictionaryDefinition[] = [] + for (const dep of refByRaw) { + if (seen.has(dep.sourceDict)) continue + seen.add(dep.sourceDict) + const def = byName.get(dep.sourceDict) + if (def) out.push(def) + } + return out + }, [allDicts, refByRaw]) + + return ( +
+ {/* Left rail: outgoing FK */} +
+ + {outgoingDicts.length === 0 ? ( +
+ {t('hub.noOutgoing', { defaultValue: 'нет исходящих ссылок' })} +
+ ) : ( +
+ {outgoingDicts.map((d) => ( + + ))} +
+ )} +
+ + {/* Center: focused dict (large card) */} +
+
+
+ {detail.scope} + {detail.approvalRequired && ( + + {t('dict.list.approval', { defaultValue: 'approval' })} + + )} + + v{detail.schemaVersion} + +
+

+ {detail.displayName ?? detail.name} +

+
+ {detail.name} +
+ {detail.description && ( +

+ {detail.description} +

+ )} +
+ {detail.bundle} + {detail.supportedLocales.join(', ')} + {typeof detail.recordCount === 'number' && ( + + {t('dict.list.recordCount', { count: detail.recordCount })} + + )} +
+
+
+ + {/* Right rail: refBy */} +
+ + {incomingDicts.length === 0 ? ( +
+ {t('hub.noIncoming', { defaultValue: 'никто не ссылается' })} +
+ ) : ( +
+ {incomingDicts.map((d) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 32cb32f..382f277 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -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 = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED']) @@ -78,6 +82,7 @@ const validateSearch = (raw: Record): 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 не - увидят этот блок. */} - + {/* View toggle: Записи (default table-based view) | Связи (Hub view с + neighbors на боках). Per design handoff B. */} + {detailQuery.data && ( +
+ {(['records', 'hub'] as const).map((mode) => { + const active = (urlSearch.view ?? 'records') === mode + return ( + + ) + })} +
+ )} + + {urlSearch.view === 'hub' && detailQuery.data ? ( + + ) : ( + /* Phase 1 dict-relationships-v2: schema-level reverse FK card. + Hide-on-empty — большинство справочников без incoming refs не + увидят этот блок. */ + + )} {aoi && (