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/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/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 && ( 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/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" >
diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx index fafdf9b..6481435 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, @@ -44,6 +45,7 @@ import { classifyRecordStatus, classifySchemaStatus, } from '@/components/reviews/MyDraftsPanel' +import { JsonView } from '@/components/JsonView' export const Route = createFileRoute('/reviews')({ component: ReviewsPage, @@ -162,6 +164,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 +474,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} @@ -614,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() @@ -749,6 +783,20 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
{draft.operation} + {dictName ? ( + + {dictName} + + ) : ( + + {draft.dictionaryId.slice(0, 8)}… + + )} + / {draft.businessKey}
@@ -808,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