From 126b797212a57ec259faa7de3e92d55fe8ebfd37 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 25 May 2026 14:51:01 +0300 Subject: [PATCH 1/4] =?UTF-8?q?fix(admin-ui):=20reviews=20=E2=80=94=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=D0=B8=D1=82=D1=8C=20dictio?= =?UTF-8?q?naryId=20=D0=B2=20=D0=B8=D0=BC=D1=8F=20=D1=81=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=80=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В таблицах /reviews (Запись + Мои → Запись) колонка «Объект» показывала UUID-prefix («254a662f…/REFLEXIO-OPTIC») вместо имени словаря, а ссылка вела на /dictionaries/ — 404 (route ожидает name, не UUID). DraftResponse несёт только dictionaryId (UUID); dictionaryName есть только у schema-drafts. Резолвим через useDictionaries() — кешируется TanStack Query, дополнительных запросов не делает. Fallback на UUID-prefix без link'а, когда dict не в выдаче (scope-hide / удалён) — лучше чем ведущая в 404 ссылка. Files: - routes/reviews.tsx — records-tab table (queue, ReviewsPage) - components/reviews/MyDraftsPanel.tsx — RecordRow ("Мои" tab) --- .../src/components/reviews/MyDraftsPanel.tsx | 36 +++++++++++++----- ordinis-admin-ui/src/routes/reviews.tsx | 38 +++++++++++++++---- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx b/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx index 6109647..11c43ea 100644 --- a/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx +++ b/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx @@ -16,7 +16,7 @@ import { TableHeaderCell, TableRow, } from '@/ui' -import { useMyDrafts, useMySchemaDrafts } from '@/api/queries' +import { useDictionaries, useMyDrafts, useMySchemaDrafts } from '@/api/queries' import { UserCell } from '@/lib/useUserDisplay' import type { DraftOperation, @@ -303,6 +303,14 @@ function RecordRow({ onClick: () => void }) { const { t } = useTranslation() + // DraftResponse несёт только dictionaryId (UUID) — резолвим в name через + // global dictionaries list (кешируется TanStack Query). Fallback на + // UUID-prefix без link'а если dict не в выдаче (scope-hide / deleted). + const dictionaries = useDictionaries() + const dictName = useMemo( + () => dictionaries.data?.find((d) => d.id === draft.dictionaryId)?.name, + [dictionaries.data, draft.dictionaryId], + ) return ( @@ -311,15 +319,23 @@ function RecordRow({ - - {draft.dictionaryId.slice(0, 8)}… - / - {draft.businessKey} - + {dictName ? ( + + {dictName} + / + {draft.businessKey} + + ) : ( + + {draft.dictionaryId.slice(0, 8)}… + / + {draft.businessKey} + + )} {draft.operation} diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx index fafdf9b..1d0e84c 100644 --- a/ordinis-admin-ui/src/routes/reviews.tsx +++ b/ordinis-admin-ui/src/routes/reviews.tsx @@ -27,6 +27,7 @@ import axios from 'axios' import { useAuth } from 'react-oidc-context' import { ArrowCounterClockwiseIcon, CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react' import { + useDictionaries, useDraft, useLiveRecord, useMyDrafts, @@ -162,6 +163,15 @@ function ReviewsPage() { const { t } = useTranslation() const queue = useReviewQueue(0, 50) const schemaQueue = useSchemaReviewQueue(0, 50) + // DraftResponse несёт только dictionaryId (UUID) — резолвим в name через + // global dictionaries list (кешируется TanStack Query, ~free). Fallback на + // UUID-prefix когда dict недоступен (scope-hide / deleted). + const dictionaries = useDictionaries() + const dictNameById = useMemo(() => { + const map = new Map() + for (const d of dictionaries.data ?? []) map.set(d.id, d.name) + return map + }, [dictionaries.data]) // Counts для "Мои" tab badge — sum of attention-needing drafts (pending + // WIP). Recompute дешёвый (linear over ≤100 items), refetch interval 30s // делает badge самозаживляющим без manual invalidation. @@ -463,13 +473,27 @@ function ReviewsPage() { /> - - {d.dictionaryId.slice(0, 8)}… - + {(() => { + const dictName = dictNameById.get(d.dictionaryId) + if (dictName) { + return ( + + {dictName} + + ) + } + // Dict скрыт scope-фильтром или удалён — UUID-prefix + // как fallback, без link'а (вёл бы в 404). + return ( + + {d.dictionaryId.slice(0, 8)}… + + ) + })()} {d.businessKey} From ac4f554e34db32598388b3c05d7460f01e716b6b Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 25 May 2026 14:55:51 +0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(admin-ui):=20reviews=20drawer=20?= =?UTF-8?q?=E2=80=94=20dict=20link,=20live=20FK,=20JSON=20syntax+diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три проблемы по reviews-drawer (по фидбеку: «Открыть ломается / Согласование не показывает справочник / не показывает live / JSON не подсвечен»): 1. useLiveRecord(draft.dictionaryId, ...) → useLiveRecord(dictName, ...). Endpoint /api/v1/dictionaries/{name}/records/{bk} ожидает name, не UUID; до фикса валился с 404 «Dictionary not found: » → live-блок был пустой и показывался "noLive" даже для UPDATE/CLOSE drafts. 2. Header drawer'а теперь рендерит " / " вместо просто businessKey. dictName — link на /dictionaries/$name. Fallback на UUID-prefix без link'а если dict не виден (scope-hide). 3. JsonView (новый компонент, ~120 строк, без deps) заменяет голые
   JSON.stringify(...):
   - syntax highlight: ключи (aurora), строки (pink), числа (warn),
     true/false/null (accent), пунктуация (mute)
   - diff highlight: top-level ключи из shallowDiffSummary получают
     фон+border (зелёный added / красный removed / жёлтый changed).
     В live-колонке — removed+changed, в proposed — added+changed.

Также добавлен error-state для liveQ (раньше 404 от useLiveRecord на UUID
молча превращался в "noLive"; теперь explicit "не удалось загрузить").
---
 ordinis-admin-ui/src/components/JsonView.tsx | 134 +++++++++++++++++++
 ordinis-admin-ui/src/routes/reviews.tsx      | 117 +++++++++++-----
 2 files changed, 217 insertions(+), 34 deletions(-)
 create mode 100644 ordinis-admin-ui/src/components/JsonView.tsx

diff --git a/ordinis-admin-ui/src/components/JsonView.tsx b/ordinis-admin-ui/src/components/JsonView.tsx
new file mode 100644
index 0000000..36270eb
--- /dev/null
+++ b/ordinis-admin-ui/src/components/JsonView.tsx
@@ -0,0 +1,134 @@
+import { useMemo, type ReactNode } from 'react'
+
+/**
+ * Лёгкий JSON viewer с token-based syntax highlight + diff-подсветкой
+ * top-level ключей. Никаких внешних зависимостей: regex-проход по
+ * `JSON.stringify(..., null, 2)`.
+ *
+ * 

Syntax: ключи (aurora), строки (pink), числа (warn), true/false/null + * (accent), пунктуация (mute). + * + *

Diff (опционально): передай diff — массив { kind, keys }. Строки + * JSON где key в added/removed/changed получают bg-цвет (зелёный / + * красный / жёлтый). Подсветка — top-level only (для глубокого diff'а + * есть DraftDiffSummary). + */ +export type JsonDiff = { + added?: Set | readonly string[] + removed?: Set | readonly string[] + changed?: Set | readonly string[] +} + +type JsonViewProps = { + value: unknown + diff?: JsonDiff + className?: string +} + +const TOKEN_RE = + /("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(true|false|null)|([{}[\],])/g + +const toSet = (v: Set | readonly string[] | undefined): Set => + v instanceof Set ? v : new Set(v ?? []) + +const tokenize = (line: string): ReactNode[] => { + const out: ReactNode[] = [] + let last = 0 + let i = 0 + for (const m of line.matchAll(TOKEN_RE)) { + const idx = m.index ?? 0 + if (idx > last) out.push(line.slice(last, idx)) + const [whole, str, colon, num, lit, punct] = m + if (str && colon) { + out.push( + + {str} + , + ) + out.push( + + {colon} + , + ) + } else if (str) { + out.push( + + {str} + , + ) + } else if (num) { + out.push( + + {num} + , + ) + } else if (lit) { + out.push( + + {lit} + , + ) + } else if (punct) { + out.push( + + {punct} + , + ) + } else { + out.push(whole) + } + last = idx + whole.length + i++ + } + if (last < line.length) out.push(line.slice(last)) + return out +} + +const LINE_KEY_RE = /^ {2}"((?:\\.|[^"\\])*)":/ + +const lineDiffClass = ( + line: string, + added: Set, + removed: Set, + changed: Set, +): string => { + const m = LINE_KEY_RE.exec(line) + if (!m) return '' + const k = m[1] + if (added.has(k)) return 'bg-aurora/15 -mx-2 px-2 border-l-2 border-aurora' + if (removed.has(k)) return 'bg-danger/15 -mx-2 px-2 border-l-2 border-danger' + if (changed.has(k)) return 'bg-warn/15 -mx-2 px-2 border-l-2 border-warn' + return '' +} + +export const JsonView = ({ value, diff, className }: JsonViewProps) => { + const added = useMemo(() => toSet(diff?.added), [diff?.added]) + const removed = useMemo(() => toSet(diff?.removed), [diff?.removed]) + const changed = useMemo(() => toSet(diff?.changed), [diff?.changed]) + + const lines = useMemo(() => { + try { + return JSON.stringify(value, null, 2).split('\n') + } catch { + return [String(value)] + } + }, [value]) + + return ( +

+      {lines.map((line, i) => {
+        const dc = lineDiffClass(line, added, removed, changed)
+        return (
+          
+ {tokenize(line)} +
+ ) + })} +
+ ) +} diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx index 1d0e84c..6481435 100644 --- a/ordinis-admin-ui/src/routes/reviews.tsx +++ b/ordinis-admin-ui/src/routes/reviews.tsx @@ -45,6 +45,7 @@ import { classifyRecordStatus, classifySchemaStatus, } from '@/components/reviews/MyDraftsPanel' +import { JsonView } from '@/components/JsonView' export const Route = createFileRoute('/reviews')({ component: ReviewsPage, @@ -638,12 +639,21 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) { const currentSub = auth.user?.profile?.sub const draftQ = useDraft(draftId) const draft = draftQ.data + // DraftResponse несёт только dictionaryId (UUID); /api/v1/dictionaries/{name}/... + // ожидает имя словаря. Резолвим через useDictionaries (TanStack кеш). + // До этого useLiveRecord(draft.dictionaryId, ...) валился с 404 + // "Dictionary not found: " → live-блок drawer'а оставался пустым. + const dictionariesQ = useDictionaries() + const dictName = useMemo( + () => dictionariesQ.data?.find((d) => d.id === draft?.dictionaryId)?.name, + [dictionariesQ.data, draft?.dictionaryId], + ) // CREATE-черновик не имеет live-записи по определению — не пробуем её - // загружать (GET /dictionaries/{id}/records/{key} → 404, красный шум в + // загружать (GET /dictionaries/{name}/records/{key} → 404, красный шум в // консоли на каждый CREATE-draft). Для UPDATE/CLOSE — грузим как раньше. const isCreateDraft = draft?.operation === 'CREATE' const liveQ = useLiveRecord( - draft && !isCreateDraft ? draft.dictionaryId : undefined, + draft && !isCreateDraft ? dictName : undefined, draft && !isCreateDraft ? draft.businessKey : undefined, ) const approveMut = useApproveDraft() @@ -773,6 +783,20 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
{draft.operation} + {dictName ? ( + + {dictName} + + ) : ( + + {draft.dictionaryId.slice(0, 8)}… + + )} + / {draft.businessKey}
@@ -832,38 +856,63 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) { operation={draft.operation} /> -
-
-

- {t('reviews.drawer.live')} -

- {liveQ.isLoading && } - {(liveQ.data === null || isCreateDraft) && ( -

- {t('reviews.drawer.noLive')} -

- )} - {liveQ.data && ( -
-                    {JSON.stringify(liveQ.data.data, null, 2)}
-                  
- )} -
-
-

- {t('reviews.drawer.proposed')} -

- {draft.operation === 'CLOSE' ? ( -

- {t('reviews.drawer.closeNote')} -

- ) : ( -
-                    {JSON.stringify(draft.data, null, 2)}
-                  
- )} -
-
+ {(() => { + // Считаем diff один раз для обеих колонок: changed/added/removed + // keys (top-level) подсветим через JsonView в обоих side-by-side + // блоках — reviewer видит точку изменения визуально, не сравнивая + // глазами длинные JSON-объекты. + const liveData = + (liveQ.data?.data as Record | null | undefined) ?? null + const proposedData = + (draft.data as Record | null | undefined) ?? null + const diff = + draft.operation === 'CLOSE' + ? null + : shallowDiffSummary(liveData, proposedData) + const liveDiff = diff + ? { removed: diff.removed, changed: diff.changed } + : undefined + const proposedDiff = diff + ? { added: diff.added, changed: diff.changed } + : undefined + return ( +
+
+

+ {t('reviews.drawer.live')} +

+ {liveQ.isLoading && } + {liveQ.error && ( +

+ {t('reviews.drawer.liveError', { + defaultValue: 'Не удалось загрузить текущую запись', + })} +

+ )} + {!liveQ.isLoading && !liveQ.error && (liveQ.data === null || isCreateDraft) && ( +

+ {t('reviews.drawer.noLive')} +

+ )} + {liveQ.data && ( + + )} +
+
+

+ {t('reviews.drawer.proposed')} +

+ {draft.operation === 'CLOSE' ? ( +

+ {t('reviews.drawer.closeNote')} +

+ ) : ( + + )} +
+
+ ) + })()} {(approveMut.error || rejectMut.error || withdrawMut.error) && (() => { const err = approveMut.error || rejectMut.error || withdrawMut.error From b828225b28dfe75b254e0c3deca3391d38ab66e0 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 25 May 2026 14:59:57 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(admin-ui):=20badge=20=D0=BD=D0=B0=20?= =?UTF-8?q?=C2=AB=D0=9C=D0=BE=D0=B8=C2=BB=20=D0=BD=D0=B5=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D1=8F=D0=BB=D1=81=D1=8F=20+=20editor?= =?UTF-8?q?=20wider=20=D0=BD=D0=B0=20=D0=B4=D0=B5=D1=81=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D0=BF=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. useApproveDraft/useRejectDraft теперь инвалидируют ['drafts'] key (my-drafts + by-dict lists). До этого инвалидировался только ['draft'] (single by id), поэтому after-approve "Мои" badge продолжал показывать pending-счётчик даже когда все драфты уже APPROVED — useMyDrafts отдавал стейл-кеш. useWithdrawDraft уже это делал, теперь подтянули approve/reject. 2. DictionaryEditorDialog: на десктопе (≥880px) modal теперь max-w 1400px вместо max-w-5xl (880px после маппинга в SIZE_CLASSES.xl). На <880px осталось w-[95vw] (compact mobile modal). На 1440p мониторе editor больше не выглядит вжатым, tabs+schema+preview помещаются без скролла. Override через panelClassName c `!` чтобы перебить SIZE_CLASSES. --- ordinis-admin-ui/src/api/mutations.ts | 6 ++++++ .../src/components/schema/DictionaryEditorDialog.tsx | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index 9df50d5..ff95cbc 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -442,6 +442,10 @@ export const useApproveDraft = () => { qc.invalidateQueries({ queryKey: ['review-queue'] }) qc.invalidateQueries({ queryKey: ['records'] }) qc.invalidateQueries({ queryKey: ['draft'] }) + // ['drafts'] = my-drafts list + by-dict list. Без этого "Мои" tab + // badge продолжал показывать счётчик после approve (status + // pending → decided, но cached list не обновлялся). + qc.invalidateQueries({ queryKey: ['drafts'] }) }, }) } @@ -467,6 +471,8 @@ export const useRejectDraft = () => { onSuccess: () => { qc.invalidateQueries({ queryKey: ['review-queue'] }) qc.invalidateQueries({ queryKey: ['draft'] }) + // Та же причина что в useApproveDraft — "Мои" badge не обновлялся. + qc.invalidateQueries({ queryKey: ['drafts'] }) }, }) } diff --git a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx index 2ca8916..2b48088 100644 --- a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx +++ b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx @@ -263,8 +263,14 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props isOpen={open} onClose={onClose} title={isEdit ? `${t('schema.action.edit')}: ${initial?.name}` : t('schema.action.create')} - maxWidth="max-w-5xl" - panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col" + // Desktop ≥880px — expanded (max-w 1400px, реально ~95vw на меньших + // десктопных широтах). Mobile <880px — наследует w-[95vw] от + // DialogContent (compact, full-width modal). Override через + // panelClassName c `!` чтобы перебить SIZE_CLASSES.xl (880px). + // Раньше maxWidth="max-w-5xl" → xl → 880px — на 1440p мониторе + // editor выглядел вжатым, форма с tabs не помещалась без скролла. + size="md" + panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col min-[880px]:!max-w-[1400px]" bodyClassName="overflow-y-auto flex-1" >
From 85177b530ccdacc21bc69fb82df0ca50daacd94a Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 25 May 2026 15:02:13 +0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(admin-ui):=20=D1=81=D0=B0=D0=B9=D0=B4?= =?UTF-8?q?=D0=B1=D0=B0=D1=80=20=D0=B2=20collapsed-mode=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20badge=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20corner=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В collapsed-режиме (56px полоска) рендерили только иконку без label и без badge — счётчик pending записей/драфтов был не виден, юзер не понимал что есть необработанные элементы пока не раскроет сайдбар. Теперь в collapsed: badge → маленький круглый pill в углу иконки (top-right, 16×16, ring-1 от bg чтобы хорошо отделялся от иконки). Cap "99+" если число большое — uniform width. Tooltip на link включает label + (count) при hover. Expanded-mode без изменений — badge справа от label как был. --- .../src/components/layout/Sidebar.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/ordinis-admin-ui/src/components/layout/Sidebar.tsx b/ordinis-admin-ui/src/components/layout/Sidebar.tsx index be0ac80..5d180f1 100644 --- a/ordinis-admin-ui/src/components/layout/Sidebar.tsx +++ b/ordinis-admin-ui/src/components/layout/Sidebar.tsx @@ -583,7 +583,17 @@ function SidebarLink({ - - {!collapsed && ( + {/* Icon + corner-badge overlay в collapsed-mode. Wrapper relative нужен + для absolute badge'а; в expanded-mode иконка без обёртки + badge + справа от label'а как было. */} + {collapsed ? ( + + + {badge !== undefined && ( + + )} + + ) : ( <> + {label} {badge !== undefined && (