Files
mdm-ordinis/ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx
T
2026-05-11 09:08:38 +00:00

214 lines
7.7 KiB
TypeScript

/**
* Hub view (View B из design handoff) — focused dictionary в центре + соседи
* по обе стороны.
*
* Layout:
* ┌──────────────┬─────────────────┬──────────────┐
* │ → ссылается │ <current> │ ← 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 '@/ui'
import { useDictionaryDependents, useDictionaries } from '@/api/queries'
import type { DictionaryDetail, DictionaryDefinition, JsonSchema } from '@/api/client'
const SCOPE_STRIP: Record<string, string> = {
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<string>()
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'
}) => (
<Link
to="/dictionaries/$name"
params={{ name: dict.name }}
search={{ view: 'hub' }}
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'
}`}
>
<div className={`flex items-baseline gap-2 ${side === 'left' ? 'justify-end' : ''}`}>
<span
className={`inline-block size-1.5 rounded-full shrink-0 ${
SCOPE_STRIP[dict.scope] ?? 'bg-carbon/40'
}`}
aria-hidden="true"
/>
<h4 className="font-sans text-[14px] font-semibold text-accent truncate group-hover:underline">
{dict.displayName ?? dict.name}
</h4>
</div>
<div className="mt-0.5 font-mono text-[10px] text-mute truncate">
{dict.name}
</div>
</Link>
)
const SectionLabel = ({
side,
count,
}: {
side: 'left' | 'right'
count: number
}) => {
const { t } = useTranslation()
return (
<div
className={`text-2xs uppercase tracking-[0.10em] text-mute mb-2 ${
side === 'left' ? 'text-right' : 'text-left'
}`}
>
{side === 'left'
? t('hub.outgoing', { count, defaultValue: '→ ссылается ({{count}})' })
: t('hub.incoming', { count, defaultValue: '← {{count}} использ.' })}
</div>
)
}
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<string>()
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 (
<section
aria-label={t('hub.heading', { defaultValue: 'Связи справочника' })}
className="grid grid-cols-1 md:grid-cols-[1fr_minmax(280px,_360px)_1fr] gap-4 md:gap-6 items-start py-2"
>
{/* Left rail: outgoing FK */}
<div className="md:order-1 order-2 space-y-2">
<SectionLabel side="left" count={outgoingDicts.length} />
{outgoingDicts.length === 0 ? (
<div className="text-2xs text-mute/70 text-right italic">
{t('hub.noOutgoing', { defaultValue: 'нет исходящих ссылок' })}
</div>
) : (
<div className="space-y-2">
{outgoingDicts.map((d) => (
<NeighborCard key={d.id} dict={d} side="left" />
))}
</div>
)}
</div>
{/* Center: focused dict (large card) */}
<div className="md:order-2 order-1 relative">
<div
className="relative bg-white rounded-xl p-5 border-2 border-accent"
style={{ boxShadow: '0 18px 36px -22px rgba(20,30,140,.5)' }}
>
<div className="flex items-start gap-2 mb-2 flex-wrap">
<Badge variant="info">{detail.scope}</Badge>
{detail.approvalRequired && (
<Badge variant="warning">
{t('dict.list.approval', { defaultValue: 'approval' })}
</Badge>
)}
<span className="ml-auto font-mono text-[11px] text-mute">
v{detail.schemaVersion}
</span>
</div>
<h2 className="font-sans text-[22px] font-semibold text-accent leading-tight">
{detail.displayName ?? detail.name}
</h2>
<div className="font-mono text-[11px] text-mute mt-0.5">
{detail.name}
</div>
{detail.description && (
<p className="text-[13px] text-ink mt-3 leading-relaxed">
{detail.description}
</p>
)}
<div className="mt-4 pt-3 border-t border-line flex items-center justify-between text-2xs uppercase tracking-[0.10em] text-mute">
<span>{detail.bundle}</span>
<span>{detail.supportedLocales.join(', ')}</span>
{typeof detail.recordCount === 'number' && (
<span>
{t('dict.list.recordCount', { count: detail.recordCount })}
</span>
)}
</div>
</div>
</div>
{/* Right rail: refBy */}
<div className="md:order-3 order-3 space-y-2">
<SectionLabel side="right" count={incomingDicts.length} />
{incomingDicts.length === 0 ? (
<div className="text-2xs text-mute/70 italic">
{t('hub.noIncoming', { defaultValue: 'никто не ссылается' })}
</div>
) : (
<div className="space-y-2">
{incomingDicts.map((d) => (
<NeighborCard key={d.id} dict={d} side="right" />
))}
</div>
)}
</div>
</section>
)
}