feat(admin-ui): Phase 1 lineage UI — "Used by" + record refs cards

dict-relationships-v2 epic, Phase 1 admin UI. Backend endpoints поднимаются
коммитом 46a0a9c, тут — UI surface'ит их.

Что добавлено:
- `DictionaryDependentsPanel` — schema-level "На этот словарь ссылаются:"
  panel на странице справочника. Chip per (sourceDict.field → targetField,
  active records count, onClose mode badge: block/warn/cascade).
  Hide-on-empty: большинство справочников без incoming refs panel не видят.
- `RecordDependentsPanel` — record-level "На эту запись ссылаются:" section
  внутри `RecordHistoryDrawer`. Per-source summary chips + paginated items
  list (size=20, prev/next). Hide-on-empty.

Types в `client.ts`: `OnCloseAction`, `SchemaDependent`, `RecordDependent`,
`PerSourceSummary`, `RecordDependentsPage`.

Queries в `queries.ts`:
- `useDictionaryDependents(name)` — staleTime 5min (schemas slow-moving),
  404/403 → empty.
- `useRecordDependents(dict, key, page, size)` — disabled пока key undefined.

Mount points:
- `routes/dictionaries.$name.tsx`: `<DictionaryDependentsPanel>` после
  PageHeader, перед AOI banner.
- `components/record/RecordHistoryDrawer.tsx`: `<RecordDependentsPanel>`
  выше existing version timeline.

i18n RU/EN:
- `lineage.usedBy.{title,count,activeCount,openSourceDict}`
- `lineage.refs.{title,total,field,created,openSourceRecord}`
- `lineage.onClose.{BLOCK,WARN,CASCADE}` (показ как лейблы)
- `pagination.{prev,next,pageOf}` — общие keys.

Plurals: `_one/_few/_many/_other` для RU (правильная грамматика count'ов).

Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS (4.04s).
- pnpm build: clean (warnings о chunk size — pre-existing для @nstart/ui).

Бекенд endpoints живут на:
- GET /api/v1/dictionaries/{name}/dependents
- GET /api/v1/records/{dict}/{key}/dependents?page&size
This commit is contained in:
Zimin A.N.
2026-05-08 10:52:46 +03:00
parent 46a0a9c00c
commit 6a365fcef7
7 changed files with 410 additions and 0 deletions
@@ -0,0 +1,81 @@
import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import { Badge, Panel } from '@nstart/ui'
import { ArrowRightIcon } from '@phosphor-icons/react'
import { useDictionaryDependents } from '@/api/queries'
import type { OnCloseAction } from '@/api/client'
type Props = {
dictionaryName: string
}
const onCloseVariant = (a: OnCloseAction): 'neutral' | 'warning' | 'error' => {
if (a === 'CASCADE') return 'error'
if (a === 'WARN') return 'warning'
return 'neutral'
}
/**
* Schema-level reverse FK card — "На этот словарь ссылаются:".
*
* <p>Показывает chip per (sourceDict.field → targetField, count, onClose mode).
* Скрывает себя если 0 dependents (не loading и не error) — большинство
* справочников не имеют incoming FK, не нужно занимать место.
*
* <p>Phase 1 (read-only). Phase 3 добавит preview cascade chain в close
* confirmation dialog.
*/
export const DictionaryDependentsPanel = ({ dictionaryName }: Props) => {
const { t } = useTranslation()
const { data, isLoading, error } = useDictionaryDependents(dictionaryName)
// Hide entirely в loading / error / empty — это "secondary" info card,
// не должен конкурировать с records table за внимание.
if (isLoading || error) return null
if (!data || data.length === 0) return null
return (
<Panel>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-primary text-carbon">
{t('lineage.usedBy.title')}
</h3>
<span className="text-2xs text-carbon/60">
{t('lineage.usedBy.count', { count: data.length })}
</span>
</div>
<ul className="flex flex-wrap gap-2">
{data.map((d) => (
<li
key={`${d.sourceDict}.${d.sourceField}`}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-sm border border-regolith bg-white text-2xs"
>
<Link
to="/dictionaries/$name"
params={{ name: d.sourceDict }}
className="text-ultramarain hover:underline font-mono"
aria-label={t('lineage.usedBy.openSourceDict', {
dict: d.sourceDisplayName ?? d.sourceDict,
})}
>
{d.sourceDisplayName ?? d.sourceDict}
</Link>
<span className="font-mono text-carbon/70">.{d.sourceField}</span>
<ArrowRightIcon weight="bold" size={12} className="text-carbon/40" />
<span className="font-mono text-carbon/70">{d.targetField}</span>
{d.activeRecordsInSourceDict > 0 && (
<Badge variant="neutral">
{t('lineage.usedBy.activeCount', {
count: d.activeRecordsInSourceDict,
})}
</Badge>
)}
<Badge variant={onCloseVariant(d.onClose)}>
{t(`lineage.onClose.${d.onClose}`)}
</Badge>
</li>
))}
</ul>
</Panel>
)
}
@@ -0,0 +1,158 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import { Alert, Badge, Button, LoadingBlock } from '@nstart/ui'
import { CaretLeftIcon, CaretRightIcon, ArrowSquareOutIcon } from '@phosphor-icons/react'
import { useRecordDependents } from '@/api/queries'
import type { OnCloseAction } from '@/api/client'
type Props = {
dictionaryName: string
businessKey: string | undefined
}
const onCloseVariant = (a: OnCloseAction): 'neutral' | 'warning' | 'error' => {
if (a === 'CASCADE') return 'error'
if (a === 'WARN') return 'warning'
return 'neutral'
}
/**
* Record-level dependents card — "На эту запись ссылаются:".
*
* <p>Показывает per-source summary (count + onClose mode) + paginated list
* actual referencing records. Используется в RecordHistoryDrawer на странице
* детали записи.
*
* <p>Hidden если record имеет 0 dependents (most records).
*
* <p>Pagination: prev/next через page state, default size=20. Server cap = 200.
*/
export const RecordDependentsPanel = ({ dictionaryName, businessKey }: Props) => {
const { t } = useTranslation()
const [page, setPage] = useState(0)
const size = 20
const { data, isLoading, error } = useRecordDependents(
dictionaryName,
businessKey,
page,
size,
)
if (!businessKey) return null
if (isLoading) return <LoadingBlock size="sm" label={t('loading')} />
if (error) {
return (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)
}
if (!data || data.total === 0) return null
const totalPages = Math.max(1, Math.ceil(data.total / size))
return (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-primary text-carbon">
{t('lineage.refs.title')}
</h3>
<span className="text-2xs text-carbon/60">
{t('lineage.refs.total', { count: data.total })}
</span>
</div>
{/* Per-source summary chips */}
<ul className="flex flex-wrap gap-1.5">
{data.perSource.map((s) => (
<li
key={`${s.sourceDict}.${s.sourceField}`}
className="flex items-center gap-1.5 px-2 py-1 rounded-sm border border-regolith bg-regolith/30 text-2xs"
>
<Link
to="/dictionaries/$name"
params={{ name: s.sourceDict }}
className="text-ultramarain hover:underline font-mono"
>
{s.sourceDisplayName ?? s.sourceDict}
</Link>
<span className="font-mono text-carbon/70">.{s.sourceField}</span>
<Badge variant="neutral">{s.count}</Badge>
<Badge variant={onCloseVariant(s.onClose)}>
{t(`lineage.onClose.${s.onClose}`)}
</Badge>
</li>
))}
</ul>
{/* Items list */}
{data.items.length > 0 && (
<ul className="divide-y divide-regolith border border-regolith rounded-sm">
{data.items.map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-3 px-3 py-2 text-2xs"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-carbon/70">{r.sourceDict}</span>
<span className="text-carbon/40">/</span>
<Link
to="/dictionaries/$name"
params={{ name: r.sourceDict }}
search={{ q: r.businessKey }}
className="text-ultramarain hover:underline font-mono truncate"
aria-label={t('lineage.refs.openSourceRecord', {
key: r.businessKey,
})}
>
{r.businessKey}
</Link>
<ArrowSquareOutIcon
weight="bold"
size={12}
className="text-carbon/40 shrink-0"
/>
</div>
<div className="text-carbon/60 mt-0.5">
{t('lineage.refs.field', { field: r.sourceField })} ·{' '}
{t('lineage.refs.created', {
when: new Date(r.createdAt).toLocaleString(),
})}
</div>
</div>
</li>
))}
</ul>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between text-2xs">
<Button
variant="secondary"
size="sm"
leftIcon={<CaretLeftIcon weight="bold" size={12} />}
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
{t('pagination.prev')}
</Button>
<span className="text-carbon/70">
{t('pagination.pageOf', { page: page + 1, total: totalPages })}
</span>
<Button
variant="secondary"
size="sm"
rightIcon={<CaretRightIcon weight="bold" size={12} />}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
{t('pagination.next')}
</Button>
</div>
)}
</section>
)
}