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 && (
+
+ {typeof badge === 'number' && badge > 99 ? '99+' : badge}
+
+ )}
+
+ ) : (
<>
+
{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