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
+44
View File
@@ -242,6 +242,50 @@ export type WebhookDeliveryPage = {
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 = {
/** success | failure | rejected */
status: 'success' | 'failure' | 'rejected'
+66
View File
@@ -8,7 +8,9 @@ import {
type DlqPage,
type FlattenedRecord,
type OutboxStats,
type RecordDependentsPage,
type RecordResponse,
type SchemaDependent,
type WebhookDeliveryPage,
type WebhookSubscription,
} from './client'
@@ -265,6 +267,70 @@ export const webhookDlqQuery = (page: number, size: number) =>
export const useWebhookDlq = (page: number, size: number) =>
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 useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
export const useRecords = (