From 6a365fcef70f5f8afe0cc9b6f95690ba121e7306 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Fri, 8 May 2026 10:52:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin-ui):=20Phase=201=20lineage=20UI=20?= =?UTF-8?q?=E2=80=94=20"Used=20by"=20+=20record=20refs=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`: `` после PageHeader, перед AOI banner. - `components/record/RecordHistoryDrawer.tsx`: `` выше 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 --- ordinis-admin-ui/src/api/client.ts | 44 +++++ ordinis-admin-ui/src/api/queries.ts | 66 ++++++++ .../lineage/DictionaryDependentsPanel.tsx | 81 +++++++++ .../lineage/RecordDependentsPanel.tsx | 158 ++++++++++++++++++ .../components/record/RecordHistoryDrawer.tsx | 11 ++ ordinis-admin-ui/src/i18n.ts | 44 +++++ .../src/routes/dictionaries.$name.tsx | 6 + 7 files changed, 410 insertions(+) create mode 100644 ordinis-admin-ui/src/components/lineage/DictionaryDependentsPanel.tsx create mode 100644 ordinis-admin-ui/src/components/lineage/RecordDependentsPanel.tsx diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 519da70..70fbf91 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -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' diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index fe049a9..bb290f4 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -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 => { + try { + const { data } = await apiClient.get( + `/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 => { + const { data } = await apiClient.get( + `/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 = ( diff --git a/ordinis-admin-ui/src/components/lineage/DictionaryDependentsPanel.tsx b/ordinis-admin-ui/src/components/lineage/DictionaryDependentsPanel.tsx new file mode 100644 index 0000000..a5605b6 --- /dev/null +++ b/ordinis-admin-ui/src/components/lineage/DictionaryDependentsPanel.tsx @@ -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 — "На этот словарь ссылаются:". + * + *

Показывает chip per (sourceDict.field → targetField, count, onClose mode). + * Скрывает себя если 0 dependents (не loading и не error) — большинство + * справочников не имеют incoming FK, не нужно занимать место. + * + *

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 ( + +

+

+ {t('lineage.usedBy.title')} +

+ + {t('lineage.usedBy.count', { count: data.length })} + +
+
    + {data.map((d) => ( +
  • + + {d.sourceDisplayName ?? d.sourceDict} + + .{d.sourceField} + + {d.targetField} + {d.activeRecordsInSourceDict > 0 && ( + + {t('lineage.usedBy.activeCount', { + count: d.activeRecordsInSourceDict, + })} + + )} + + {t(`lineage.onClose.${d.onClose}`)} + +
  • + ))} +
+ + ) +} diff --git a/ordinis-admin-ui/src/components/lineage/RecordDependentsPanel.tsx b/ordinis-admin-ui/src/components/lineage/RecordDependentsPanel.tsx new file mode 100644 index 0000000..358669b --- /dev/null +++ b/ordinis-admin-ui/src/components/lineage/RecordDependentsPanel.tsx @@ -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 — "На эту запись ссылаются:". + * + *

Показывает per-source summary (count + onClose mode) + paginated list + * actual referencing records. Используется в RecordHistoryDrawer на странице + * детали записи. + * + *

Hidden если record имеет 0 dependents (most records). + * + *

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 + if (error) { + return ( + + {String(error)} + + ) + } + if (!data || data.total === 0) return null + + const totalPages = Math.max(1, Math.ceil(data.total / size)) + + return ( +

+
+

+ {t('lineage.refs.title')} +

+ + {t('lineage.refs.total', { count: data.total })} + +
+ + {/* Per-source summary chips */} +
    + {data.perSource.map((s) => ( +
  • + + {s.sourceDisplayName ?? s.sourceDict} + + .{s.sourceField} + {s.count} + + {t(`lineage.onClose.${s.onClose}`)} + +
  • + ))} +
+ + {/* Items list */} + {data.items.length > 0 && ( +
    + {data.items.map((r) => ( +
  • +
    +
    + {r.sourceDict} + / + + {r.businessKey} + + +
    +
    + {t('lineage.refs.field', { field: r.sourceField })} ·{' '} + {t('lineage.refs.created', { + when: new Date(r.createdAt).toLocaleString(), + })} +
    +
    +
  • + ))} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + {t('pagination.pageOf', { page: page + 1, total: totalPages })} + + +
+ )} +
+ ) +} diff --git a/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx b/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx index 40722f9..e7865df 100644 --- a/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx +++ b/ordinis-admin-ui/src/components/record/RecordHistoryDrawer.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import { Alert, Badge, Drawer, LoadingBlock } from '@nstart/ui' import { useRecordHistory } from '@/api/queries' +import { RecordDependentsPanel } from '@/components/lineage/RecordDependentsPanel' type Props = { open: boolean @@ -22,6 +23,16 @@ export const RecordHistoryDrawer = ({ open, onClose, dictionaryName, businessKey widthClassName="w-full max-w-2xl" >
+ {/* Phase 1 dict-relationships-v2: surface incoming references first. + Component сам hide'ится при 0 dependents — для большинства записей + этот блок не появится. */} + {open && ( + + )} + {isLoading && } {error && ( diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 746bb37..dc56011 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -230,6 +230,31 @@ i18n 'history.validTo': 'действует до', 'history.updatedBy': 'кем', '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.addProperty': 'Добавить поле', 'schema.delete': 'Удалить', @@ -514,6 +539,25 @@ i18n 'history.validTo': 'valid to', 'history.updatedBy': 'by', '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.addProperty': 'Add field', 'schema.delete': 'Delete', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index a9d9e57..0dcc5aa 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -28,6 +28,7 @@ import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRe import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client' import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' +import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel' import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog' 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 не + увидят этот блок. */} + + {aoi && (