Merge branch 'fix/reviews-dict-uuid-to-name' into 'main'
fix(admin-ui): reviews+sidebar polish (6 фиксов одной пачкой) See merge request 2-6/2-6-4/terravault/ordinis!259
This commit is contained in:
@@ -442,6 +442,10 @@ export const useApproveDraft = () => {
|
|||||||
qc.invalidateQueries({ queryKey: ['review-queue'] })
|
qc.invalidateQueries({ queryKey: ['review-queue'] })
|
||||||
qc.invalidateQueries({ queryKey: ['records'] })
|
qc.invalidateQueries({ queryKey: ['records'] })
|
||||||
qc.invalidateQueries({ queryKey: ['draft'] })
|
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: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['review-queue'] })
|
qc.invalidateQueries({ queryKey: ['review-queue'] })
|
||||||
qc.invalidateQueries({ queryKey: ['draft'] })
|
qc.invalidateQueries({ queryKey: ['draft'] })
|
||||||
|
// Та же причина что в useApproveDraft — "Мои" badge не обновлялся.
|
||||||
|
qc.invalidateQueries({ queryKey: ['drafts'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useMemo, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лёгкий JSON viewer с token-based syntax highlight + diff-подсветкой
|
||||||
|
* top-level ключей. Никаких внешних зависимостей: regex-проход по
|
||||||
|
* `JSON.stringify(..., null, 2)`.
|
||||||
|
*
|
||||||
|
* <p>Syntax: ключи (aurora), строки (pink), числа (warn), true/false/null
|
||||||
|
* (accent), пунктуация (mute).
|
||||||
|
*
|
||||||
|
* <p>Diff (опционально): передай diff — массив { kind, keys }. Строки
|
||||||
|
* JSON где key в added/removed/changed получают bg-цвет (зелёный /
|
||||||
|
* красный / жёлтый). Подсветка — top-level only (для глубокого diff'а
|
||||||
|
* есть DraftDiffSummary).
|
||||||
|
*/
|
||||||
|
export type JsonDiff = {
|
||||||
|
added?: Set<string> | readonly string[]
|
||||||
|
removed?: Set<string> | readonly string[]
|
||||||
|
changed?: Set<string> | 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<string> | readonly string[] | undefined): Set<string> =>
|
||||||
|
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(
|
||||||
|
<span key={`k${i}`} className="text-aurora">
|
||||||
|
{str}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
out.push(
|
||||||
|
<span key={`c${i}`} className="text-mute">
|
||||||
|
{colon}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
} else if (str) {
|
||||||
|
out.push(
|
||||||
|
<span key={`s${i}`} className="text-pink">
|
||||||
|
{str}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
} else if (num) {
|
||||||
|
out.push(
|
||||||
|
<span key={`n${i}`} className="text-warn">
|
||||||
|
{num}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
} else if (lit) {
|
||||||
|
out.push(
|
||||||
|
<span key={`l${i}`} className="text-accent">
|
||||||
|
{lit}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
} else if (punct) {
|
||||||
|
out.push(
|
||||||
|
<span key={`p${i}`} className="text-mute">
|
||||||
|
{punct}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
} 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<string>,
|
||||||
|
removed: Set<string>,
|
||||||
|
changed: Set<string>,
|
||||||
|
): 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 (
|
||||||
|
<pre
|
||||||
|
className={
|
||||||
|
'text-mono text-cell bg-line/20 rounded p-2 overflow-x-auto whitespace-pre ' +
|
||||||
|
(className ?? '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
const dc = lineDiffClass(line, added, removed, changed)
|
||||||
|
return (
|
||||||
|
<div key={i} className={dc}>
|
||||||
|
{tokenize(line)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -583,7 +583,17 @@ function SidebarLink({
|
|||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={collapsed ? (typeof label === 'string' ? label : undefined) : undefined}
|
// Tooltip в collapsed-mode включает label + текущий badge (если есть),
|
||||||
|
// чтобы юзер видел число при hover на узкой полоске.
|
||||||
|
title={
|
||||||
|
collapsed
|
||||||
|
? typeof label === 'string'
|
||||||
|
? badge !== undefined
|
||||||
|
? `${label} (${badge})`
|
||||||
|
: label
|
||||||
|
: undefined
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
// text-body = 13px workhorse per handoff (см. styles.css @utility).
|
// text-body = 13px workhorse per handoff (см. styles.css @utility).
|
||||||
'group flex items-center rounded-md text-body transition-colors',
|
'group flex items-center rounded-md text-body transition-colors',
|
||||||
@@ -593,9 +603,28 @@ function SidebarLink({
|
|||||||
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
|
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={collapsed ? 18 : 15} strokeWidth={1.75} className="shrink-0" />
|
{/* Icon + corner-badge overlay в collapsed-mode. Wrapper relative нужен
|
||||||
{!collapsed && (
|
для absolute badge'а; в expanded-mode иконка без обёртки + badge
|
||||||
|
справа от label'а как было. */}
|
||||||
|
{collapsed ? (
|
||||||
|
<span className="relative inline-flex items-center justify-center">
|
||||||
|
<Icon size={18} strokeWidth={1.75} className="shrink-0" />
|
||||||
|
{badge !== undefined && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'absolute -top-1.5 -right-1.5 inline-flex items-center justify-center',
|
||||||
|
'min-w-[1rem] h-4 px-1 rounded-full text-cap font-mono leading-none',
|
||||||
|
'bg-accent text-on-accent ring-1 ring-surface',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{typeof badge === 'number' && badge > 99 ? '99+' : badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Icon size={15} strokeWidth={1.75} className="shrink-0" />
|
||||||
<span className="flex-1 truncate">{label}</span>
|
<span className="flex-1 truncate">{label}</span>
|
||||||
{badge !== undefined && (
|
{badge !== undefined && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
TableHeaderCell,
|
TableHeaderCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import { useMyDrafts, useMySchemaDrafts } from '@/api/queries'
|
import { useDictionaries, useMyDrafts, useMySchemaDrafts } from '@/api/queries'
|
||||||
import { UserCell } from '@/lib/useUserDisplay'
|
import { UserCell } from '@/lib/useUserDisplay'
|
||||||
import type {
|
import type {
|
||||||
DraftOperation,
|
DraftOperation,
|
||||||
@@ -303,6 +303,14 @@ function RecordRow({
|
|||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
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 (
|
return (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -311,15 +319,23 @@ function RecordRow({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
{dictName ? (
|
||||||
to="/dictionaries/$name"
|
<Link
|
||||||
params={{ name: draft.dictionaryId }}
|
to="/dictionaries/$name"
|
||||||
className="text-mono text-accent hover:underline"
|
params={{ name: dictName }}
|
||||||
>
|
className="text-mono text-accent hover:underline"
|
||||||
<span className="text-mute">{draft.dictionaryId.slice(0, 8)}…</span>
|
>
|
||||||
<span className="mx-1 text-mute">/</span>
|
<span>{dictName}</span>
|
||||||
<span>{draft.businessKey}</span>
|
<span className="mx-1 text-mute">/</span>
|
||||||
</Link>
|
<span>{draft.businessKey}</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-mono" title={draft.dictionaryId}>
|
||||||
|
<span className="text-mute">{draft.dictionaryId.slice(0, 8)}…</span>
|
||||||
|
<span className="mx-1 text-mute">/</span>
|
||||||
|
<span>{draft.businessKey}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
|
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
|
||||||
|
|||||||
@@ -263,8 +263,14 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
|||||||
isOpen={open}
|
isOpen={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={isEdit ? `${t('schema.action.edit')}: ${initial?.name}` : t('schema.action.create')}
|
title={isEdit ? `${t('schema.action.edit')}: ${initial?.name}` : t('schema.action.create')}
|
||||||
maxWidth="max-w-5xl"
|
// Desktop ≥880px — expanded (max-w 1400px, реально ~95vw на меньших
|
||||||
panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col"
|
// десктопных широтах). 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"
|
bodyClassName="overflow-y-auto flex-1"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import axios from 'axios'
|
|||||||
import { useAuth } from 'react-oidc-context'
|
import { useAuth } from 'react-oidc-context'
|
||||||
import { ArrowCounterClockwiseIcon, CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
|
import { ArrowCounterClockwiseIcon, CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
|
||||||
import {
|
import {
|
||||||
|
useDictionaries,
|
||||||
useDraft,
|
useDraft,
|
||||||
useLiveRecord,
|
useLiveRecord,
|
||||||
useMyDrafts,
|
useMyDrafts,
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
classifyRecordStatus,
|
classifyRecordStatus,
|
||||||
classifySchemaStatus,
|
classifySchemaStatus,
|
||||||
} from '@/components/reviews/MyDraftsPanel'
|
} from '@/components/reviews/MyDraftsPanel'
|
||||||
|
import { JsonView } from '@/components/JsonView'
|
||||||
|
|
||||||
export const Route = createFileRoute('/reviews')({
|
export const Route = createFileRoute('/reviews')({
|
||||||
component: ReviewsPage,
|
component: ReviewsPage,
|
||||||
@@ -162,6 +164,15 @@ function ReviewsPage() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queue = useReviewQueue(0, 50)
|
const queue = useReviewQueue(0, 50)
|
||||||
const schemaQueue = useSchemaReviewQueue(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<string, string>()
|
||||||
|
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 +
|
// Counts для "Мои" tab badge — sum of attention-needing drafts (pending +
|
||||||
// WIP). Recompute дешёвый (linear over ≤100 items), refetch interval 30s
|
// WIP). Recompute дешёвый (linear over ≤100 items), refetch interval 30s
|
||||||
// делает badge самозаживляющим без manual invalidation.
|
// делает badge самозаживляющим без manual invalidation.
|
||||||
@@ -463,13 +474,27 @@ function ReviewsPage() {
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
{(() => {
|
||||||
to="/dictionaries/$name"
|
const dictName = dictNameById.get(d.dictionaryId)
|
||||||
params={{ name: d.dictionaryId }}
|
if (dictName) {
|
||||||
className="text-mono text-accent hover:underline"
|
return (
|
||||||
>
|
<Link
|
||||||
{d.dictionaryId.slice(0, 8)}…
|
to="/dictionaries/$name"
|
||||||
</Link>
|
params={{ name: dictName }}
|
||||||
|
className="text-mono text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{dictName}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Dict скрыт scope-фильтром или удалён — UUID-prefix
|
||||||
|
// как fallback, без link'а (вёл бы в 404).
|
||||||
|
return (
|
||||||
|
<span className="text-mono text-mute" title={d.dictionaryId}>
|
||||||
|
{d.dictionaryId.slice(0, 8)}…
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-mono">{d.businessKey}</TableCell>
|
<TableCell className="text-mono">{d.businessKey}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -614,12 +639,21 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
|||||||
const currentSub = auth.user?.profile?.sub
|
const currentSub = auth.user?.profile?.sub
|
||||||
const draftQ = useDraft(draftId)
|
const draftQ = useDraft(draftId)
|
||||||
const draft = draftQ.data
|
const draft = draftQ.data
|
||||||
|
// DraftResponse несёт только dictionaryId (UUID); /api/v1/dictionaries/{name}/...
|
||||||
|
// ожидает имя словаря. Резолвим через useDictionaries (TanStack кеш).
|
||||||
|
// До этого useLiveRecord(draft.dictionaryId, ...) валился с 404
|
||||||
|
// "Dictionary not found: <uuid>" → live-блок drawer'а оставался пустым.
|
||||||
|
const dictionariesQ = useDictionaries()
|
||||||
|
const dictName = useMemo(
|
||||||
|
() => dictionariesQ.data?.find((d) => d.id === draft?.dictionaryId)?.name,
|
||||||
|
[dictionariesQ.data, draft?.dictionaryId],
|
||||||
|
)
|
||||||
// CREATE-черновик не имеет live-записи по определению — не пробуем её
|
// CREATE-черновик не имеет live-записи по определению — не пробуем её
|
||||||
// загружать (GET /dictionaries/{id}/records/{key} → 404, красный шум в
|
// загружать (GET /dictionaries/{name}/records/{key} → 404, красный шум в
|
||||||
// консоли на каждый CREATE-draft). Для UPDATE/CLOSE — грузим как раньше.
|
// консоли на каждый CREATE-draft). Для UPDATE/CLOSE — грузим как раньше.
|
||||||
const isCreateDraft = draft?.operation === 'CREATE'
|
const isCreateDraft = draft?.operation === 'CREATE'
|
||||||
const liveQ = useLiveRecord(
|
const liveQ = useLiveRecord(
|
||||||
draft && !isCreateDraft ? draft.dictionaryId : undefined,
|
draft && !isCreateDraft ? dictName : undefined,
|
||||||
draft && !isCreateDraft ? draft.businessKey : undefined,
|
draft && !isCreateDraft ? draft.businessKey : undefined,
|
||||||
)
|
)
|
||||||
const approveMut = useApproveDraft()
|
const approveMut = useApproveDraft()
|
||||||
@@ -749,6 +783,20 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
|||||||
<div className="space-y-1 text-cell">
|
<div className="space-y-1 text-cell">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
|
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
|
||||||
|
{dictName ? (
|
||||||
|
<Link
|
||||||
|
to="/dictionaries/$name"
|
||||||
|
params={{ name: dictName }}
|
||||||
|
className="text-mono text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{dictName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-mono text-mute" title={draft.dictionaryId}>
|
||||||
|
{draft.dictionaryId.slice(0, 8)}…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-mute">/</span>
|
||||||
<span className="font-mono">{draft.businessKey}</span>
|
<span className="font-mono">{draft.businessKey}</span>
|
||||||
<DrawerStatusBadge status={draft.status} />
|
<DrawerStatusBadge status={draft.status} />
|
||||||
</div>
|
</div>
|
||||||
@@ -808,38 +856,63 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
|||||||
operation={draft.operation}
|
operation={draft.operation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
{(() => {
|
||||||
<div>
|
// Считаем diff один раз для обеих колонок: changed/added/removed
|
||||||
<p className="text-cap text-mute mb-1">
|
// keys (top-level) подсветим через JsonView в обоих side-by-side
|
||||||
{t('reviews.drawer.live')}
|
// блоках — reviewer видит точку изменения визуально, не сравнивая
|
||||||
</p>
|
// глазами длинные JSON-объекты.
|
||||||
{liveQ.isLoading && <LoadingBlock size="sm" label={t('loading')} />}
|
const liveData =
|
||||||
{(liveQ.data === null || isCreateDraft) && (
|
(liveQ.data?.data as Record<string, unknown> | null | undefined) ?? null
|
||||||
<p className="text-cell text-mute italic">
|
const proposedData =
|
||||||
{t('reviews.drawer.noLive')}
|
(draft.data as Record<string, unknown> | null | undefined) ?? null
|
||||||
</p>
|
const diff =
|
||||||
)}
|
draft.operation === 'CLOSE'
|
||||||
{liveQ.data && (
|
? null
|
||||||
<pre className="text-mono bg-line/30 rounded p-2 overflow-x-auto whitespace-pre-wrap">
|
: shallowDiffSummary(liveData, proposedData)
|
||||||
{JSON.stringify(liveQ.data.data, null, 2)}
|
const liveDiff = diff
|
||||||
</pre>
|
? { removed: diff.removed, changed: diff.changed }
|
||||||
)}
|
: undefined
|
||||||
</div>
|
const proposedDiff = diff
|
||||||
<div>
|
? { added: diff.added, changed: diff.changed }
|
||||||
<p className="text-cap text-mute mb-1">
|
: undefined
|
||||||
{t('reviews.drawer.proposed')}
|
return (
|
||||||
</p>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{draft.operation === 'CLOSE' ? (
|
<div>
|
||||||
<p className="text-cell text-aurora italic">
|
<p className="text-cap text-mute mb-1">
|
||||||
{t('reviews.drawer.closeNote')}
|
{t('reviews.drawer.live')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
{liveQ.isLoading && <LoadingBlock size="sm" label={t('loading')} />}
|
||||||
<pre className="text-mono bg-accent/4 rounded p-2 overflow-x-auto whitespace-pre-wrap">
|
{liveQ.error && (
|
||||||
{JSON.stringify(draft.data, null, 2)}
|
<p className="text-cell text-danger italic">
|
||||||
</pre>
|
{t('reviews.drawer.liveError', {
|
||||||
)}
|
defaultValue: 'Не удалось загрузить текущую запись',
|
||||||
</div>
|
})}
|
||||||
</div>
|
</p>
|
||||||
|
)}
|
||||||
|
{!liveQ.isLoading && !liveQ.error && (liveQ.data === null || isCreateDraft) && (
|
||||||
|
<p className="text-cell text-mute italic">
|
||||||
|
{t('reviews.drawer.noLive')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{liveQ.data && (
|
||||||
|
<JsonView value={liveQ.data.data} diff={liveDiff} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-cap text-mute mb-1">
|
||||||
|
{t('reviews.drawer.proposed')}
|
||||||
|
</p>
|
||||||
|
{draft.operation === 'CLOSE' ? (
|
||||||
|
<p className="text-cell text-aurora italic">
|
||||||
|
{t('reviews.drawer.closeNote')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<JsonView value={draft.data} diff={proposedDiff} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{(approveMut.error || rejectMut.error || withdrawMut.error) && (() => {
|
{(approveMut.error || rejectMut.error || withdrawMut.error) && (() => {
|
||||||
const err = approveMut.error || rejectMut.error || withdrawMut.error
|
const err = approveMut.error || rejectMut.error || withdrawMut.error
|
||||||
|
|||||||
Reference in New Issue
Block a user