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:
@@ -242,6 +242,50 @@ export type WebhookDeliveryPage = {
|
|||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Dependents (Phase 1 dict-relationships-v2) ===
|
||||||
|
|
||||||
|
export type OnCloseAction = 'BLOCK' | 'WARN' | 'CASCADE'
|
||||||
|
|
||||||
|
export type SchemaDependent = {
|
||||||
|
sourceDict: string
|
||||||
|
sourceDisplayName?: string | null
|
||||||
|
sourceField: string
|
||||||
|
targetField: string
|
||||||
|
onClose: OnCloseAction
|
||||||
|
/** Total active records in source dict (для UI badge "X записей всего"). */
|
||||||
|
activeRecordsInSourceDict: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecordDependent = {
|
||||||
|
sourceDict: string
|
||||||
|
sourceDisplayName?: string | null
|
||||||
|
sourceField: string
|
||||||
|
onClose: OnCloseAction
|
||||||
|
id: string
|
||||||
|
businessKey: string
|
||||||
|
validFrom: string
|
||||||
|
validTo: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PerSourceSummary = {
|
||||||
|
sourceDict: string
|
||||||
|
sourceDisplayName?: string | null
|
||||||
|
sourceField: string
|
||||||
|
onClose: OnCloseAction
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecordDependentsPage = {
|
||||||
|
targetDict: string
|
||||||
|
targetBusinessKey: string
|
||||||
|
items: RecordDependent[]
|
||||||
|
perSource: PerSourceSummary[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
export type WebhookTestPingResult = {
|
export type WebhookTestPingResult = {
|
||||||
/** success | failure | rejected */
|
/** success | failure | rejected */
|
||||||
status: 'success' | 'failure' | 'rejected'
|
status: 'success' | 'failure' | 'rejected'
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
type DlqPage,
|
type DlqPage,
|
||||||
type FlattenedRecord,
|
type FlattenedRecord,
|
||||||
type OutboxStats,
|
type OutboxStats,
|
||||||
|
type RecordDependentsPage,
|
||||||
type RecordResponse,
|
type RecordResponse,
|
||||||
|
type SchemaDependent,
|
||||||
type WebhookDeliveryPage,
|
type WebhookDeliveryPage,
|
||||||
type WebhookSubscription,
|
type WebhookSubscription,
|
||||||
} from './client'
|
} from './client'
|
||||||
@@ -265,6 +267,70 @@ export const webhookDlqQuery = (page: number, size: number) =>
|
|||||||
export const useWebhookDlq = (page: number, size: number) =>
|
export const useWebhookDlq = (page: number, size: number) =>
|
||||||
useQuery(webhookDlqQuery(page, size))
|
useQuery(webhookDlqQuery(page, size))
|
||||||
|
|
||||||
|
// === Dependents (Phase 1 dict-relationships-v2) ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema-level reverse FK list для target dict — какие dict.field ссылаются.
|
||||||
|
* 5 min staleTime: schema-level changes идут через bundle import (редко).
|
||||||
|
*
|
||||||
|
* 404 / 403 → пустой list (consistent с serverside scope-hide).
|
||||||
|
*/
|
||||||
|
export const dictionaryDependentsQuery = (name: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['dictionary-dependents', name] as const,
|
||||||
|
queryFn: async (): Promise<SchemaDependent[]> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<SchemaDependent[]>(
|
||||||
|
`/dictionaries/${encodeURIComponent(name)}/dependents`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const status = (e as { response?: { status?: number } })?.response?.status
|
||||||
|
if (status === 404 || status === 403) return []
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useDictionaryDependents = (name: string | undefined) =>
|
||||||
|
useQuery({
|
||||||
|
...dictionaryDependentsQuery(name ?? ''),
|
||||||
|
enabled: Boolean(name),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record-level dependents — paginated. Disabled пока businessKey пустой
|
||||||
|
* (closed drawer / no selection).
|
||||||
|
*/
|
||||||
|
export const recordDependentsQuery = (
|
||||||
|
dict: string,
|
||||||
|
businessKey: string,
|
||||||
|
page: number,
|
||||||
|
size: number,
|
||||||
|
) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['record-dependents', dict, businessKey, page, size] as const,
|
||||||
|
queryFn: async (): Promise<RecordDependentsPage> => {
|
||||||
|
const { data } = await apiClient.get<RecordDependentsPage>(
|
||||||
|
`/records/${encodeURIComponent(dict)}/${encodeURIComponent(businessKey)}/dependents`,
|
||||||
|
{ params: { page, size } },
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useRecordDependents = (
|
||||||
|
dict: string,
|
||||||
|
businessKey: string | undefined,
|
||||||
|
page = 0,
|
||||||
|
size = 50,
|
||||||
|
) =>
|
||||||
|
useQuery({
|
||||||
|
...recordDependentsQuery(dict, businessKey ?? '', page, size),
|
||||||
|
enabled: Boolean(businessKey),
|
||||||
|
})
|
||||||
|
|
||||||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||||||
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
||||||
export const useRecords = (
|
export const useRecords = (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Alert, Badge, Drawer, LoadingBlock } from '@nstart/ui'
|
import { Alert, Badge, Drawer, LoadingBlock } from '@nstart/ui'
|
||||||
import { useRecordHistory } from '@/api/queries'
|
import { useRecordHistory } from '@/api/queries'
|
||||||
|
import { RecordDependentsPanel } from '@/components/lineage/RecordDependentsPanel'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -22,6 +23,16 @@ export const RecordHistoryDrawer = ({ open, onClose, dictionaryName, businessKey
|
|||||||
widthClassName="w-full max-w-2xl"
|
widthClassName="w-full max-w-2xl"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Phase 1 dict-relationships-v2: surface incoming references first.
|
||||||
|
Component сам hide'ится при 0 dependents — для большинства записей
|
||||||
|
этот блок не появится. */}
|
||||||
|
{open && (
|
||||||
|
<RecordDependentsPanel
|
||||||
|
dictionaryName={dictionaryName}
|
||||||
|
businessKey={businessKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
|
|||||||
@@ -230,6 +230,31 @@ i18n
|
|||||||
'history.validTo': 'действует до',
|
'history.validTo': 'действует до',
|
||||||
'history.updatedBy': 'кем',
|
'history.updatedBy': 'кем',
|
||||||
'history.viewData': 'данные записи (JSON)',
|
'history.viewData': 'данные записи (JSON)',
|
||||||
|
// === Lineage (dict-relationships-v2 Phase 1) ===
|
||||||
|
'lineage.usedBy.title': 'На этот словарь ссылаются',
|
||||||
|
'lineage.usedBy.count_one': '{{count}} связь',
|
||||||
|
'lineage.usedBy.count_few': '{{count}} связи',
|
||||||
|
'lineage.usedBy.count_many': '{{count}} связей',
|
||||||
|
'lineage.usedBy.count_other': '{{count}} связей',
|
||||||
|
'lineage.usedBy.activeCount_one': '{{count}} запись',
|
||||||
|
'lineage.usedBy.activeCount_few': '{{count}} записи',
|
||||||
|
'lineage.usedBy.activeCount_many': '{{count}} записей',
|
||||||
|
'lineage.usedBy.activeCount_other': '{{count}} записей',
|
||||||
|
'lineage.usedBy.openSourceDict': 'Открыть словарь {{dict}}',
|
||||||
|
'lineage.refs.title': 'На эту запись ссылаются',
|
||||||
|
'lineage.refs.total_one': 'всего {{count}} ссылка',
|
||||||
|
'lineage.refs.total_few': 'всего {{count}} ссылки',
|
||||||
|
'lineage.refs.total_many': 'всего {{count}} ссылок',
|
||||||
|
'lineage.refs.total_other': 'всего {{count}} ссылок',
|
||||||
|
'lineage.refs.field': 'поле {{field}}',
|
||||||
|
'lineage.refs.created': 'создана {{when}}',
|
||||||
|
'lineage.refs.openSourceRecord': 'Открыть запись {{key}} в источнике',
|
||||||
|
'lineage.onClose.BLOCK': 'block',
|
||||||
|
'lineage.onClose.WARN': 'warn',
|
||||||
|
'lineage.onClose.CASCADE': 'cascade',
|
||||||
|
'pagination.prev': 'Назад',
|
||||||
|
'pagination.next': 'Вперёд',
|
||||||
|
'pagination.pageOf': 'стр. {{page}} из {{total}}',
|
||||||
'schema.empty': 'Полей нет — добавь первое',
|
'schema.empty': 'Полей нет — добавь первое',
|
||||||
'schema.addProperty': 'Добавить поле',
|
'schema.addProperty': 'Добавить поле',
|
||||||
'schema.delete': 'Удалить',
|
'schema.delete': 'Удалить',
|
||||||
@@ -514,6 +539,25 @@ i18n
|
|||||||
'history.validTo': 'valid to',
|
'history.validTo': 'valid to',
|
||||||
'history.updatedBy': 'by',
|
'history.updatedBy': 'by',
|
||||||
'history.viewData': 'record data (JSON)',
|
'history.viewData': 'record data (JSON)',
|
||||||
|
// === Lineage (dict-relationships-v2 Phase 1) ===
|
||||||
|
'lineage.usedBy.title': 'Used by',
|
||||||
|
'lineage.usedBy.count_one': '{{count}} reference',
|
||||||
|
'lineage.usedBy.count_other': '{{count}} references',
|
||||||
|
'lineage.usedBy.activeCount_one': '{{count}} record',
|
||||||
|
'lineage.usedBy.activeCount_other': '{{count}} records',
|
||||||
|
'lineage.usedBy.openSourceDict': 'Open dictionary {{dict}}',
|
||||||
|
'lineage.refs.title': 'References to this record',
|
||||||
|
'lineage.refs.total_one': '{{count}} reference total',
|
||||||
|
'lineage.refs.total_other': '{{count}} references total',
|
||||||
|
'lineage.refs.field': 'field {{field}}',
|
||||||
|
'lineage.refs.created': 'created {{when}}',
|
||||||
|
'lineage.refs.openSourceRecord': 'Open referencing record {{key}}',
|
||||||
|
'lineage.onClose.BLOCK': 'block',
|
||||||
|
'lineage.onClose.WARN': 'warn',
|
||||||
|
'lineage.onClose.CASCADE': 'cascade',
|
||||||
|
'pagination.prev': 'Prev',
|
||||||
|
'pagination.next': 'Next',
|
||||||
|
'pagination.pageOf': 'page {{page}} of {{total}}',
|
||||||
'schema.empty': 'No fields yet — add the first one',
|
'schema.empty': 'No fields yet — add the first one',
|
||||||
'schema.addProperty': 'Add field',
|
'schema.addProperty': 'Add field',
|
||||||
'schema.delete': 'Delete',
|
'schema.delete': 'Delete',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRe
|
|||||||
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
||||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||||
|
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
|
||||||
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
||||||
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
||||||
import { nowIsoLocal } from '@/lib/dates'
|
import { nowIsoLocal } from '@/lib/dates'
|
||||||
@@ -412,6 +413,11 @@ function DictionaryDetail() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Phase 1 dict-relationships-v2: schema-level reverse FK card.
|
||||||
|
Hide-on-empty — большинство справочников без incoming refs не
|
||||||
|
увидят этот блок. */}
|
||||||
|
<DictionaryDependentsPanel dictionaryName={name} />
|
||||||
|
|
||||||
{aoi && (
|
{aoi && (
|
||||||
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-ultramarain/30 bg-ultramarain/4 text-sm">
|
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-ultramarain/30 bg-ultramarain/4 text-sm">
|
||||||
<span className="font-mono text-xs text-carbon/80">
|
<span className="font-mono text-xs text-carbon/80">
|
||||||
|
|||||||
Reference in New Issue
Block a user