diff --git a/ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx b/ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx index 3478965..1656ab7 100644 --- a/ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx +++ b/ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx @@ -1,99 +1,63 @@ /** - * Hub view (View B из design handoff) — focused dictionary в центре + соседи - * по обе стороны. + * DictionaryHubView — Связи tab per redesign/screens/04-editor-links.png. * - * Layout: - * ┌──────────────┬─────────────────┬──────────────┐ - * │ → ссылается │ │ ← N использ. │ - * │ (outgoing) │ (focused) │ (refBy) │ - * └──────────────┴─────────────────┴──────────────┘ + *

2-column split: + *

* - * Outgoing FK derived from schemaJson properties[*].x-references - * (format: "dict.field"). Incoming refBy via useDictionaryDependents. - * - * Click на neighbor card → navigate к его Hub view (recursion fine). + *

Replaces старый 3-col hub view с central dict card — center больше не + * нужен (всё info уже в InfoPanel слева). */ import { useMemo } from 'react' import { Link } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' -import { Badge } from '@/ui' -import { useDictionaryDependents, useDictionaries } from '@/api/queries' -import type { DictionaryDetail, DictionaryDefinition, JsonSchema } from '@/api/client' +import { useDictionaries, useDictionaryDependents } from '@/api/queries' +import type { + DictionaryDefinition, + DictionaryDetail, + JsonSchema, + SchemaDependent, +} from '@/api/client' +import { cn } from '@/lib/utils' -const SCOPE_STRIP: Record = { - PUBLIC: 'bg-emerald-500', - INTERNAL: 'bg-amber-500', - RESTRICTED: 'bg-rose-500', +type OutgoingFkRow = { + /** Target dict name. */ + target: string + /** Source field name in current schema. */ + sourceField: string + /** Target field name (usually .id или .code). */ + targetField: string + /** Required в JSON schema. */ + required: boolean } -/** - * Walk schemaJson properties — собираем targets из x-references. - * Format: "targetDict.targetField" → возвращаем уникальные targetDict names. - */ -const extractOutgoingFk = (schema: JsonSchema | undefined): string[] => { +/** Walk schema properties → build outgoing FK rows. */ +const extractOutgoingFk = (schema: JsonSchema | undefined): OutgoingFkRow[] => { if (!schema?.properties) return [] - const targets = new Set() - for (const prop of Object.values(schema.properties)) { + const required = new Set(schema.required ?? []) + const rows: OutgoingFkRow[] = [] + for (const [field, prop] of Object.entries(schema.properties)) { const ref = prop['x-references'] if (typeof ref === 'string' && ref.length > 0) { - const [target] = ref.split('.') - if (target) targets.add(target) + const [target, targetField] = ref.split('.') + if (target) { + rows.push({ + target, + sourceField: field, + targetField: targetField ?? 'id', + required: required.has(field), + }) + } } } - 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}} использ.' })} -
- ) + return rows.sort((a, b) => a.target.localeCompare(b.target)) } export function DictionaryHubView({ detail }: { detail: DictionaryDetail }) { @@ -101,113 +65,146 @@ export function DictionaryHubView({ detail }: { detail: DictionaryDetail }) { const { data: allDicts } = useDictionaries() const { data: refByRaw } = useDictionaryDependents(detail.name) - // Outgoing FK targets — names derived из schemaJson. - const outgoingNames = useMemo( - () => extractOutgoingFk(detail.schemaJson), - [detail.schemaJson], + const outgoing = useMemo(() => extractOutgoingFk(detail.schemaJson), [detail.schemaJson]) + const incoming: SchemaDependent[] = useMemo(() => refByRaw ?? [], [refByRaw]) + + // Resolve target dict definitions для record count badge. + const dictByName = useMemo(() => { + if (!allDicts) return new Map() + return new Map(allDicts.map((d) => [d.name, d])) + }, [allDicts]) + + // Aggregate total incoming deps count для warning footer. + const totalIncomingDeps = useMemo( + () => incoming.reduce((sum, d) => sum + d.activeRecordsInSourceDict, 0), + [incoming], ) - - // 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]) + const HEAVY_THRESHOLD = 100 return ( -
- {/* Left rail: outgoing FK */} -
- - {outgoingDicts.length === 0 ? ( -
- {t('hub.noOutgoing', { defaultValue: 'нет исходящих ссылок' })} +
+ {/* Outgoing → */} +
+
+
+ → {t('hub.outgoing', { defaultValue: 'ссылается на' })} + ({outgoing.length}) +
+
+ {outgoing.length === 0 ? ( +
+ {t('hub.empty.outgoing', { + defaultValue: 'Нет исходящих FK', + })}
) : ( -
- {outgoingDicts.map((d) => ( - - ))} -
+
    + {outgoing.map((row) => { + const target = dictByName.get(row.target) + return ( +
  • + + + {row.target} + + {target?.recordCount !== undefined && ( + + {target.recordCount} + + )} + + {row.required ? 'required' : 'optional'} + + + {row.sourceField} + + +
  • + ) + })} +
)} -
+
- {/* Center: focused dict (large card) */} -
-
-
- {detail.scope} - {detail.approvalRequired && ( - - {t('dict.list.approval', { defaultValue: 'approval' })} - - )} - - v{detail.schemaVersion} - + {/* Incoming ← */} +
+
+
+ ← {t('hub.incoming', { defaultValue: 'ссылаются на нас' })} + ({incoming.length})
-

- {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: 'никто не ссылается' })} + + {incoming.length === 0 ? ( +
+ {t('hub.empty.incoming', { + defaultValue: 'Никто не ссылается', + })}
) : ( -
- {incomingDicts.map((d) => ( - - ))} -
+ <> +
    + {incoming.map((dep) => ( +
  • + + + {dep.sourceDict} + + {dep.activeRecordsInSourceDict > 0 && ( + + {dep.activeRecordsInSourceDict} + + )} + {dep.onClose === 'BLOCK' && ( + + {t('hub.critical', { defaultValue: 'критично' })} + + )} + {dep.onClose === 'CASCADE' && ( + + CASCADE + + )} + + .{dep.sourceField} + + +
  • + ))} +
+ {/* Footer warning если в this dict много incoming refs — удаление + * записи будет сложным (cascade chain или blockers). */} + {totalIncomingDeps > HEAVY_THRESHOLD && ( +
+ {t('hub.heavyDeps', { + count: totalIncomingDeps, + defaultValue: `Удаление записи в этом справочнике сложно — ${totalIncomingDeps} ссылок`, + })} +
+ )} + )} -
- + +
) } diff --git a/ordinis-admin-ui/src/routes/dictionaries.index.tsx b/ordinis-admin-ui/src/routes/dictionaries.index.tsx index b121eb9..68de1dd 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.index.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.index.tsx @@ -23,7 +23,7 @@ import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents } f import type { DataScope, DictionaryDefinition, SchemaDependent } from '@/api/client' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' import { useCanMutate } from '@/auth/useCanMutate' -import { SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style' +import { SCOPE_BG_TINT, SCOPE_DOT, SCOPE_ORDER, SCOPE_TEXT } from '@/lib/scope-style' // ===== Search params ===== @@ -248,7 +248,7 @@ function DictionariesPage() { title={t(`dict.list.section.${scope}`)} className={`text-cap tracking-[0.14em] uppercase font-semibold px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${ selected - ? 'border-ink bg-surface text-ink ring-1 ring-ink' + ? `${SCOPE_BG_TINT[scope]} ${SCOPE_TEXT[scope]} border-transparent` : 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2' }`} > @@ -473,18 +473,28 @@ function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) { } } + // Per redesign prototype: row левый край имеет 4px stripe в scope color + + // INTERNAL/RESTRICTED rows получают subtle bg tint для visual scope grouping. + // PUBLIC = plain white (default — самый common scope не нужно подкрашивать). + const rowTint = d.scope === 'PUBLIC' ? '' : `${SCOPE_BG_TINT[d.scope]}/30` + return ( - {/* name + subtitle (description short) */} - -
- + {/* name + subtitle (description short) + scope-color left stripe */} + + {/* 3px scope stripe — absolutely positioned на левом краю row. + * td имеет relative чтобы before работало в table context. */} + +
{d.displayName ?? d.name} @@ -493,7 +503,7 @@ function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) { )}
{d.description && ( -

+

{d.description}

)} @@ -506,9 +516,13 @@ function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) { {d.bundle} - {/* scope badge */} + {/* scope badge — colored по scope (PUBLIC accent / INTERNAL warn / RESTRICTED pink) */} - {d.scope} + + {d.scope} + {/* records count */}