Files
mdm-ordinis/ordinis-admin-ui/src/routes/reviews.tsx
T
2026-06-02 13:02:13 +00:00

1123 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
Badge,
Button,
Checkbox,
Drawer,
EmptyState,
LoadingBlock,
Modal,
PageHeader,
Panel,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Tabs,
TextArea,
TextInput,
QueryErrorState,
} from '@/ui'
import axios from 'axios'
import { useAuth } from '@/auth/useAuth'
import { useIsAdmin } from '@/auth/usePermissions'
import { ArrowCounterClockwiseIcon, CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
import {
useDictionaries,
useDraft,
useLiveRecord,
useMyDrafts,
useMySchemaDrafts,
useReviewQueue,
useSchemaReviewQueue,
} from '@/api/queries'
import { useApproveDraft, useRejectDraft, useWithdrawDraft } from '@/api/mutations'
import type { DraftOperation, DraftResponse, DraftStatus } from '@/api/client'
import { UserCell } from '@/lib/useUserDisplay'
import { shallowDiffSummary, isEmptyDiffSummary } from '@/lib/diff'
import { humanizeBulkReason } from '@/lib/bulkReason'
import {
MyDraftsPanel,
classifyRecordStatus,
classifySchemaStatus,
} from '@/components/reviews/MyDraftsPanel'
import { JsonView } from '@/components/JsonView'
export const Route = createFileRoute('/reviews')({
component: ReviewsPage,
})
const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' => {
if (op === 'CREATE') return 'success'
if (op === 'CLOSE') return 'warning'
return 'info'
}
/**
* Record draft status badge — используется в drawer header'е, чтобы maker
* сразу видел "в каком состоянии мой draft" без скролла к decision banner.
*
* <p>Visual mapping повторяет {@link MyDraftsPanel} badge'и для consistency:
* PENDING=info, APPROVED=success, REJECTED=danger, WITHDRAWN=default.
*/
function DrawerStatusBadge({ status }: { status: DraftStatus }) {
const { t } = useTranslation()
const variant: 'info' | 'success' | 'danger' | 'default' = (() => {
switch (status) {
case 'PENDING':
return 'info'
case 'APPROVED':
return 'success'
case 'REJECTED':
return 'danger'
case 'WITHDRAWN':
return 'default'
}
})()
const fallback: Record<DraftStatus, string> = {
PENDING: 'На ревью',
APPROVED: 'Одобрено',
REJECTED: 'Отклонён',
WITHDRAWN: 'Отозван',
}
return (
<Badge variant={variant}>
{t(`reviews.mine.recordStatus.${status}`, { defaultValue: fallback[status] })}
</Badge>
)
}
/**
* Diff summary chips for the ReviewDrawer — "+N added (fields), M removed,
* ~K changed" above the side-by-side JSON panes. Reviewer sees the gist
* without eyeball-diffing long records.
*
* Backed by `shallowDiffSummary` from `@/lib/diff` (extracted there during
* the 2026-05-14 eng review of MR !186 for reuse — RecordHistoryDrawer and
* audit row summaries will plug into the same helper). Memo'd against the
* three actual inputs so it doesn't recompute on unrelated state changes
* (textarea typing, drawer resize, etc.).
*/
function DraftDiffSummary({
live,
proposed,
operation,
}: {
live: Record<string, unknown> | null | undefined
proposed: Record<string, unknown> | null | undefined
operation: DraftOperation
}) {
const summary = useMemo(() => {
// CLOSE op has no proposed payload by design — skip without computing.
if (operation === 'CLOSE') return null
return shallowDiffSummary(live, proposed)
}, [live, proposed, operation])
if (!summary || isEmptyDiffSummary(summary)) return null
return (
<div className="flex flex-wrap items-center gap-3 text-mono text-cap tabular-nums">
{summary.added.length > 0 && (
<span className="inline-flex items-center gap-1 text-aurora">
<span className="font-bold">+</span>
<span>{summary.added.length}</span>
<span className="text-mute lowercase">({summary.added.join(', ')})</span>
</span>
)}
{summary.removed.length > 0 && (
<span className="inline-flex items-center gap-1 text-danger">
<span className="font-bold"></span>
<span>{summary.removed.length}</span>
<span className="text-mute lowercase">({summary.removed.join(', ')})</span>
</span>
)}
{summary.changed.length > 0 && (
<span className="inline-flex items-center gap-1 text-warn">
<span className="font-bold">~</span>
<span>{summary.changed.length}</span>
<span className="text-mute lowercase">({summary.changed.join(', ')})</span>
</span>
)}
</div>
)
}
/**
* Approval Workflow v2 — reviewer queue (D3=A pool model).
*
* <p>Все pending drafts в ordinis FIFO ordered by submitted_at. Click row →
* drawer открывается с side-by-side current vs proposed JSON + Approve /
* Reject actions. Reject reason required.
*/
type BulkResult = {
approved: { id: string; bk: string }[]
rejected: { id: string; bk: string }[]
failed: { id: string; bk: string; reason: string }[]
}
type ReviewsTab = 'records' | 'schemas' | 'mine'
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<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 +
// WIP). Recompute дешёвый (linear over ≤100 items), refetch interval 30s
// делает badge самозаживляющим без manual invalidation.
const myDrafts = useMyDrafts(0, 50)
const mySchemaDrafts = useMySchemaDrafts(0, 50)
const recordsCount = queue.data?.totalElements ?? 0
const schemasCount = schemaQueue.data?.totalElements ?? 0
const myActiveCount = useMemo(() => {
const records = (myDrafts.data?.items ?? []).filter(
(r) => classifyRecordStatus(r.status) === 'pending',
).length
const schemas = (mySchemaDrafts.data?.items ?? []).filter((s) => {
const b = classifySchemaStatus(s.status)
return b === 'pending' || b === 'wip'
}).length
return records + schemas
}, [myDrafts.data, mySchemaDrafts.data])
// Tab дефолтит на records (старее, чаще). Если schema queue не пустой
// а record queue пустой — переключаемся на schemas чтобы reviewer'у не
// пришлось кликать дважды.
const [activeTab, setActiveTab] = useState<ReviewsTab>('records')
// Auto-switch один раз когда queries loaded и records=0/schemas>0.
useMemo(() => {
if (recordsCount === 0 && schemasCount > 0 && activeTab === 'records') {
setActiveTab('schemas')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recordsCount, schemasCount])
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
/** Phase 4: bulk approve/reject. Set<draftId> — стабильное id'шник. */
const [selection, setSelection] = useState<Set<string>>(new Set())
const [bulkRejectOpen, setBulkRejectOpen] = useState(false)
const [bulkRejectReason, setBulkRejectReason] = useState('')
const [bulkRunning, setBulkRunning] = useState(false)
const [bulkResult, setBulkResult] = useState<BulkResult | undefined>(undefined)
const approveMut = useApproveDraft()
const rejectMut = useRejectDraft()
const items = queue.data?.items ?? []
const visibleIds = useMemo(() => items.map((d) => d.id), [items])
const allVisibleSelected =
visibleIds.length > 0 && visibleIds.every((id) => selection.has(id))
const anySelected = selection.size > 0
const toggleSelect = (id: string) => {
setSelection((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (allVisibleSelected) setSelection(new Set())
else setSelection(new Set(visibleIds))
}
/**
* Bulk-execute approve или reject параллельно. Каждый mutation independent
* — если один fail (e.g. 409 self_approve_forbidden, 409 draft_not_pending
* race), остальные продолжают. Result summary показывает кто прошёл и кто
* нет с reason'ом.
*/
const runBulk = async (
op: 'approve' | 'reject',
targets: DraftResponse[],
sharedReason: string,
) => {
setBulkRunning(true)
const result: BulkResult = { approved: [], rejected: [], failed: [] }
await Promise.all(
targets.map(async (d) => {
try {
if (op === 'approve') {
await approveMut.mutateAsync({ id: d.id, comment: undefined })
result.approved.push({ id: d.id, bk: d.businessKey })
} else {
await rejectMut.mutateAsync({ id: d.id, comment: sharedReason })
result.rejected.push({ id: d.id, bk: d.businessKey })
}
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { code?: string; message?: string } } })
?.response?.data?.code ??
(e as Error)?.message ??
'unknown'
result.failed.push({ id: d.id, bk: d.businessKey, reason: msg })
}
}),
)
setBulkResult(result)
setBulkRunning(false)
setSelection(new Set())
setBulkRejectOpen(false)
setBulkRejectReason('')
}
const handleBulkApprove = () => {
const targets = items.filter((d) => selection.has(d.id))
if (targets.length === 0) return
if (!confirm(t('reviews.bulk.confirmApprove', { count: targets.length }))) return
runBulk('approve', targets, '')
}
const handleBulkReject = () => {
if (!bulkRejectReason.trim()) return
const targets = items.filter((d) => selection.has(d.id))
if (targets.length === 0) return
runBulk('reject', targets, bulkRejectReason.trim())
}
return (
<div className="space-y-4">
<PageHeader
title={t('reviews.title')}
description={t('reviews.description')}
/>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as ReviewsTab)}
items={[
{
id: 'records',
label: t('reviews.tab.records', { defaultValue: 'Записи' }),
count:
recordsCount > 0 ? (
<Badge variant="info">{recordsCount}</Badge>
) : undefined,
},
{
id: 'schemas',
label: t('reviews.tab.schemas', { defaultValue: 'Схемы' }),
count:
schemasCount > 0 ? (
<Badge variant="info">{schemasCount}</Badge>
) : undefined,
},
{
id: 'mine',
label: t('reviews.tab.mine', { defaultValue: 'Мои' }),
count:
myActiveCount > 0 ? (
<Badge variant="info">{myActiveCount}</Badge>
) : undefined,
},
]}
/>
{activeTab === 'mine' && (
<MyDraftsPanel onRecordClick={(id) => setSelectedId(id)} />
)}
{activeTab === 'schemas' && (
<>
{schemaQueue.isLoading && <LoadingBlock size="md" label={t('loading')} />}
<SchemaReviewQueuePanel />
{schemasCount === 0 && !schemaQueue.isLoading && (
<EmptyState
title={t('reviews.schema.empty', {
defaultValue: 'Очередь пуста',
})}
description={t('reviews.schema.emptyDescription', {
defaultValue: 'Схемы на ревью появятся здесь.',
})}
/>
)}
</>
)}
{activeTab === 'records' && queue.isLoading && (
<LoadingBlock size="md" label={t('loading')} />
)}
{activeTab === 'records' && queue.error && (
<QueryErrorState error={queue.error} onRetry={() => queue.refetch()} />
)}
{activeTab === 'records' && bulkResult && (
<Alert
variant={bulkResult.failed.length === 0 ? 'success' : 'warning'}
title={t('reviews.bulk.resultTitle')}
action={
<Button variant="ghost" size="sm" onClick={() => setBulkResult(undefined)}>
{t('reviews.bulk.cancel')}
</Button>
}
>
<ul className="space-y-1 text-cell">
{bulkResult.approved.length > 0 && (
<li>
{t('reviews.bulk.approvedCount', { count: bulkResult.approved.length })}
: {bulkResult.approved.map((r) => r.bk).join(', ')}
</li>
)}
{bulkResult.rejected.length > 0 && (
<li>
{t('reviews.bulk.rejectedCount', { count: bulkResult.rejected.length })}
: {bulkResult.rejected.map((r) => r.bk).join(', ')}
</li>
)}
{bulkResult.failed.length > 0 && (
<li className="text-error">
{t('reviews.bulk.failedCount', { count: bulkResult.failed.length })}:
<ul className="ml-4 mt-1 list-disc">
{bulkResult.failed.map((f) => (
<li key={f.id}>
<span className="font-mono">{f.bk}</span>: {humanizeBulkReason(f.reason)}
</li>
))}
</ul>
</li>
)}
</ul>
</Alert>
)}
{activeTab === 'records' && queue.data && queue.data.totalElements === 0 && (
<EmptyState
icon={<CheckCircleIcon weight="duotone" size={32} />}
title={t('reviews.empty')}
description={t('reviews.emptyDescription', {
defaultValue:
'Очередь чистая. Прошлые решения можно посмотреть в журнале аудита.',
})}
action={
<Link to="/audit" className="text-accent hover:underline text-cell">
{t('reviews.emptyAuditLink', { defaultValue: 'Открыть /audit' })}
</Link>
}
/>
)}
{activeTab === 'records' && queue.data && queue.data.totalElements > 0 && (
<Panel>
<div className="flex items-center justify-between mb-3 gap-3">
<p className="text-cell text-mute">
{t('reviews.queueTotal', { count: queue.data.totalElements })}
{anySelected ? (
<span className="ml-2 text-accent font-medium">
· {t('reviews.bulk.selectedCount', { count: selection.size })}
</span>
) : null}
</p>
{anySelected && (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
disabled={bulkRunning}
onClick={handleBulkApprove}
>
{t('reviews.bulk.approveSelected')}
</Button>
<Button
variant="secondary"
size="sm"
disabled={bulkRunning}
onClick={() => setBulkRejectOpen(true)}
>
{t('reviews.bulk.rejectSelected')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setSelection(new Set())}
>
{t('reviews.bulk.clear')}
</Button>
</div>
)}
</div>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>
<Checkbox
aria-label={t('reviews.bulk.selectAll')}
checked={allVisibleSelected}
onChange={toggleSelectAll}
/>
</TableHeaderCell>
<TableHeaderCell>{t('reviews.col.dict')}</TableHeaderCell>
<TableHeaderCell>{t('reviews.col.bk')}</TableHeaderCell>
<TableHeaderCell>{t('reviews.col.op')}</TableHeaderCell>
<TableHeaderCell>{t('reviews.col.maker')}</TableHeaderCell>
<TableHeaderCell>{t('reviews.col.submitted')}</TableHeaderCell>
<TableHeaderCell>{t('reviews.col.actions')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((d) => (
<TableRow key={d.id} data-selected={selection.has(d.id)}>
<TableCell>
<Checkbox
aria-label={`${t('reviews.bulk.selectRow')} ${d.businessKey}`}
checked={selection.has(d.id)}
onChange={() => toggleSelect(d.id)}
/>
</TableCell>
<TableCell>
{(() => {
const dictName = dictNameById.get(d.dictionaryId)
if (dictName) {
return (
<Link
to="/dictionaries/$name"
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 className="text-mono">{d.businessKey}</TableCell>
<TableCell>
<Badge variant={operationVariant(d.operation)}>{d.operation}</Badge>
</TableCell>
<TableCell><UserCell uuid={d.makerId} /></TableCell>
<TableCell className="text-cell text-ink-2">
{new Date(d.submittedAt).toLocaleString()}
</TableCell>
<TableCell>
<Button
variant="secondary"
size="sm"
onClick={() => setSelectedId(d.id)}
>
{t('reviews.action.review')}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Panel>
)}
<ReviewDrawer
draftId={selectedId}
onClose={() => setSelectedId(undefined)}
/>
<Modal
isOpen={bulkRejectOpen}
onClose={() => setBulkRejectOpen(false)}
title={t('reviews.bulk.rejectModalTitle', { count: selection.size })}
>
<div className="space-y-3">
<p className="text-cell text-ink-2">
{t('reviews.bulk.rejectModalDescription')}
</p>
<TextArea
label={t('reviews.bulk.rejectReasonLabel')}
value={bulkRejectReason}
onChange={(e) => setBulkRejectReason(e.target.value)}
rows={3}
placeholder={t('reviews.bulk.rejectReasonPlaceholder')}
/>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => {
setBulkRejectOpen(false)
setBulkRejectReason('')
}}
>
{t('reviews.bulk.cancel')}
</Button>
<Button
variant="primary"
disabled={!bulkRejectReason.trim() || bulkRunning}
onClick={handleBulkReject}
>
{t('reviews.bulk.confirmReject', { count: selection.size })}
</Button>
</div>
</div>
</Modal>
</div>
)
}
type DrawerProps = {
draftId: string | undefined
onClose: () => void
}
/**
* Превращает Axios error в human-readable title + body. Mapping для
* известных backend кодов (см. {@code OrdinisException.conflict}):
* - self_approve_forbidden — maker = reviewer (D3 maker-checker invariant)
* - draft_not_pending — кто-то уже approve/reject'нул этот draft
* - forbidden_role — у actor'а нет нужной workflow роли (Phase 1d enforcement)
*
* Default fallback — backend message + статус код. Сырой "AxiosError ... 409"
* больше не показывается.
*/
function extractReviewError(err: unknown): { title: string; body: string } {
if (!err) return { title: 'Ошибка', body: '' }
if (axios.isAxiosError(err)) {
const status = err.response?.status
const data = err.response?.data as
| { code?: string; message?: string; error?: string }
| undefined
const code = data?.code
const msg = data?.message ?? data?.error ?? err.message
if (code === 'self_approve_forbidden') {
return {
title: 'Нельзя одобрить свой собственный черновик',
body:
'Maker-checker invariant: тот, кто создал draft, не может его approve. Попроси другого reviewer\'а из пула.',
}
}
if (code === 'self_reject_forbidden') {
return {
title: 'Нельзя отклонить свой собственный черновик',
body:
'Тот, кто создал draft, не может его reject. Если передумал — используй Отозвать (withdraw).',
}
}
if (code === 'draft_not_pending') {
return {
title: 'Draft уже не pending',
body: 'Кто-то уже принял решение по этому draft\'у. Обнови очередь.',
}
}
if (code === 'forbidden_role') {
return {
title: 'Недостаточно прав',
body: msg || 'У вас нет нужной workflow role для этого действия.',
}
}
if (code === 'reject_reason_required') {
return {
title: 'Укажите причину отклонения',
body: 'Поле «Комментарий ревьюера» обязательно для reject (для compliance).',
}
}
return {
title: `Ошибка ${status ?? ''}`.trim(),
body: msg,
}
}
return {
title: 'Ошибка',
body: err instanceof Error ? err.message : String(err),
}
}
function ReviewDrawer({ draftId, onClose }: DrawerProps) {
const { t } = useTranslation()
const auth = useAuth()
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: <uuid>" → 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/{name}/records/{key} → 404, красный шум в
// консоли на каждый CREATE-draft). Для UPDATE/CLOSE — грузим как раньше.
const isCreateDraft = draft?.operation === 'CREATE'
const liveQ = useLiveRecord(
draft && !isCreateDraft ? dictName : undefined,
draft && !isCreateDraft ? draft.businessKey : undefined,
)
const approveMut = useApproveDraft()
const rejectMut = useRejectDraft()
const withdrawMut = useWithdrawDraft()
const [comment, setComment] = useState('')
/** Inline error для reject reason — show when reviewer кликнул Reject с
* пустым comment'ом. Реально cleared автоматически когда user печатает. */
const [rejectError, setRejectError] = useState<string | null>(null)
/** Brief success affordance после approve/reject mutation — drawer
* остаётся 600ms с зелёной/красной полосой, чтобы reviewer почувствовал
* что click registered. Без этого drawer закрывается мгновенно, click
* feedback теряется. */
const [decisionFlash, setDecisionFlash] = useState<'approve' | 'reject' | null>(null)
// Reset state когда reviewer переключается на другой draft без закрытия
// drawer'а (click row → open new draft в already-open drawer). Раньше
// {comment, rejectError} оставались от previous draft, и mutation .error
// объекты от approveMut/rejectMut/withdrawMut held conflict banners
// ('реверсное состояние draft', '409', etc.) из predыдущей попытки —
// показывались на форме нового draft (observed bug).
useEffect(() => {
setComment('')
setRejectError(null)
setDecisionFlash(null)
approveMut.reset()
rejectMut.reset()
withdrawMut.reset()
// approveMut/rejectMut/withdrawMut references stable между renders
// (TanStack mutation hook), но lint требует exhaustive deps. .reset
// — stable function, безопасно включить.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftId])
// Identity gates — определяют что показать в footer'е drawer'а.
//
// 1. `isPending` = false: terminal draft (APPROVED/REJECTED/WITHDRAWN).
// Никаких actions — read-only diff + decision banner.
// 2. `isMineAndPending`: maker открыл свой собственный pending draft из
// "Мои" таба. Для regular maker'а Approve self запрещён backend'ом
// (self_approve_forbidden), показываем Withdraw. Admin override
// (MR !280): backend разрешает self-approve если у actor'а admin
// роль → frontend тоже снимает блокировку, иначе UI скрывает кнопку
// которую backend бы пропустил.
// 3. Иначе — reviewer flow: approve/reject как было.
const isAdmin = useIsAdmin()
const isPending = draft?.status === 'PENDING'
const isMineAndPending =
isPending && Boolean(currentSub) && draft?.makerId === currentSub && !isAdmin
const handleApprove = () => {
if (!draftId) return
approveMut.mutate(
{ id: draftId, comment: comment.trim() || undefined },
{
onSuccess: () => {
setDecisionFlash('approve')
// Hold drawer 600ms так чтобы reviewer увидел зелёную полосу;
// потом reset state и close. Без флэша approve было instant.
setTimeout(() => {
setComment('')
setDecisionFlash(null)
onClose()
}, 600)
},
},
)
}
const handleReject = () => {
if (!draftId) return
if (!comment.trim()) {
// Раньше return был silent — reviewer кликал Reject и ничего не
// происходило. Backend всё равно вернёт 400 missing_reject_reason,
// но user-friendly inline error лучше.
setRejectError(
t('reviews.drawer.rejectReasonRequired', {
defaultValue:
'Укажите причину отклонения — comment обязателен для reject (compliance).',
}),
)
return
}
setRejectError(null)
rejectMut.mutate(
{ id: draftId, comment: comment.trim() },
{
onSuccess: () => {
setDecisionFlash('reject')
setTimeout(() => {
setComment('')
setDecisionFlash(null)
onClose()
}, 600)
},
},
)
}
const handleWithdraw = () => {
if (!draftId) return
if (!confirm(t('reviews.action.withdrawConfirm', {
defaultValue: 'Отозвать свой черновик? После этого он попадёт в Decided.',
}))) return
withdrawMut.mutate(draftId, {
onSuccess: () => {
setComment('')
onClose()
},
})
}
return (
<Drawer
isOpen={Boolean(draftId)}
onClose={onClose}
title={t('reviews.drawer.title')}
side="right"
widthClassName="w-full max-w-3xl"
>
<div className="space-y-4">
{draftQ.isLoading && <LoadingBlock size="md" label={t('loading')} />}
{draftQ.error && (
<QueryErrorState error={draftQ.error} onRetry={() => draftQ.refetch()} />
)}
{draft && (
<>
{/* Tightened header: top row is identity (op + bk), bottom row is
provenance (who/when). Was a single flex-wrap line with four
spans — visually busy, hard to scan. */}
<div className="space-y-1 text-cell">
<div className="flex flex-wrap items-center gap-2">
<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>
<DrawerStatusBadge status={draft.status} />
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-mute">
<span className="inline-flex items-center gap-1">
{t('reviews.drawer.maker')}: <UserCell uuid={draft.makerId} />
</span>
<span>
{t('reviews.drawer.submitted')}:{' '}
{new Date(draft.submittedAt).toLocaleString()}
</span>
</div>
</div>
{/* Read-only decision banner — для всех terminal статусов. Показывает
reviewer'а + decision time + reviewer comment чтобы maker'у не
пришлось гадать "что произошло". */}
{!isPending && (
<div className="px-3 py-2 rounded-sm border border-line bg-line/30 text-cell space-y-1">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-mute">
{draft.reviewerId && (
<span className="inline-flex items-center gap-1">
{t('reviews.drawer.reviewer', { defaultValue: 'Ревьюер' })}:{' '}
<UserCell uuid={draft.reviewerId} />
</span>
)}
{draft.reviewedAt && (
<span>
{t('reviews.drawer.reviewedAt', { defaultValue: 'Решение' })}:{' '}
{new Date(draft.reviewedAt).toLocaleString()}
</span>
)}
</div>
{draft.reviewComment && (
<div>
<span className="text-mute uppercase tracking-[0.10em] font-display">
{t('reviews.drawer.reviewComment', { defaultValue: 'Комментарий' })}:
</span>{' '}
{draft.reviewComment}
</div>
)}
</div>
)}
{draft.makerComment && (
<div className="px-3 py-2 rounded-sm border border-line bg-line/30 text-cell">
<span className="text-mute uppercase tracking-[0.10em] font-display">
{t('reviews.drawer.makerComment')}:
</span>{' '}
{draft.makerComment}
</div>
)}
<DraftDiffSummary
live={liveQ.data?.data as Record<string, unknown> | null | undefined}
proposed={draft.data as Record<string, unknown> | null | undefined}
operation={draft.operation}
/>
{(() => {
// Считаем diff один раз для обеих колонок: changed/added/removed
// keys (top-level) подсветим через JsonView в обоих side-by-side
// блоках — reviewer видит точку изменения визуально, не сравнивая
// глазами длинные JSON-объекты.
const liveData =
(liveQ.data?.data as Record<string, unknown> | null | undefined) ?? null
const proposedData =
(draft.data as Record<string, unknown> | 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 (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<p className="text-cap text-mute mb-1">
{t('reviews.drawer.live')}
</p>
{liveQ.isLoading && <LoadingBlock size="sm" label={t('loading')} />}
{liveQ.error && (
<p className="text-cell text-danger italic">
{t('reviews.drawer.liveError', {
defaultValue: 'Не удалось загрузить текущую запись',
})}
</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) && (() => {
const err = approveMut.error || rejectMut.error || withdrawMut.error
const info = extractReviewError(err)
return (
<Alert variant="error" title={info.title}>
{info.body}
</Alert>
)
})()}
{/* Action footer split на три режима:
- terminal (не pending) — только Close, нет input/actions
- own pending — Withdraw, без comment input (maker не reviewer'ит)
- peer pending — approve/reject (existing reviewer flow) */}
{!isPending && (
<div className="border-t border-line pt-3 flex items-center justify-end">
<Button variant="secondary" onClick={onClose}>
{t('reviews.action.close', { defaultValue: 'Закрыть' })}
</Button>
</div>
)}
{isMineAndPending && (
<div className="border-t border-line pt-3 space-y-2">
<p className="text-cell text-mute">
{t('reviews.drawer.ownPendingHint', {
defaultValue: 'Это ваш черновик на ревью. Approve/reject запрещены — можно отозвать.',
})}
</p>
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="secondary"
onClick={onClose}
disabled={withdrawMut.isPending}
>
{t('reviews.action.cancel')}
</Button>
<Button
variant="danger"
leftIcon={<ArrowCounterClockwiseIcon weight="bold" size={14} />}
onClick={handleWithdraw}
disabled={withdrawMut.isPending}
loading={withdrawMut.isPending}
>
{t('reviews.action.withdraw', { defaultValue: 'Отозвать' })}
</Button>
</div>
</div>
)}
{isPending && !isMineAndPending && (
<div
className={
'border-t pt-3 space-y-2 transition-colors duration-300 -mx-4 px-4 ' +
(decisionFlash === 'approve'
? 'border-green bg-green-bg/30'
: decisionFlash === 'reject'
? 'border-pink bg-pink-bg/30'
: 'border-line')
}
>
<label
htmlFor="review-comment"
className="text-cap text-ink-2"
>
{t('reviews.drawer.comment')}
</label>
<TextInput
id="review-comment"
value={comment}
onChange={(e) => {
setComment(e.target.value)
// Stale validation error gone как только user печатает.
if (rejectError && e.target.value.trim()) setRejectError(null)
}}
placeholder={t('reviews.drawer.commentPlaceholder')}
aria-invalid={Boolean(rejectError)}
aria-describedby={rejectError ? 'review-comment-error' : undefined}
/>
{rejectError && (
<p
id="review-comment-error"
role="alert"
className="text-cell text-pink"
>
{rejectError}
</p>
)}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="secondary"
onClick={onClose}
disabled={approveMut.isPending || rejectMut.isPending}
>
{t('reviews.action.cancel')}
</Button>
<Button
variant="danger"
leftIcon={<XCircleIcon weight="bold" size={14} />}
onClick={handleReject}
disabled={rejectMut.isPending || approveMut.isPending}
loading={rejectMut.isPending}
>
{t('reviews.action.reject')}
</Button>
<Button
variant="primary"
leftIcon={<CheckCircleIcon weight="bold" size={14} />}
onClick={handleApprove}
disabled={approveMut.isPending || rejectMut.isPending}
loading={approveMut.isPending}
>
{t('reviews.action.approve')}
</Button>
</div>
</div>
)}
</>
)}
</div>
</Drawer>
)
}
/**
* Schema review pool queue — global view всех \`review_pending\` schema
* draft'ов через {@code GET /admin/schema-reviews/pending}. Reviewer
* жмёт «Открыть» → переходит на dict detail page, banner+drawer
* автоматически подхватятся через {@code useDictActiveSchemaDraft}.
*
* <p>Скрывается если pool пуст — экономит вертикальное место на странице
* для основного record-review queue.
*/
function SchemaReviewQueuePanel() {
const { t } = useTranslation()
const queue = useSchemaReviewQueue(0, 20)
const items = queue.data?.items ?? []
if (queue.isLoading || items.length === 0) return null
return (
<Panel>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-title-md font-semibold">
{t('reviews.schema.title', { defaultValue: 'Изменения схем' })}
</h2>
<p className="text-cell text-mute mt-0.5">
{t('reviews.schema.queueTotal', {
count: queue.data?.totalElements ?? items.length,
defaultValue: `${queue.data?.totalElements ?? items.length} draft(s) ждут ревью`,
})}
</p>
</div>
<Badge variant="info">
{t('reviews.schema.label', { defaultValue: 'Schema workflow' })}
</Badge>
</div>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{t('reviews.schema.dictionary', { defaultValue: 'Словарь' })}</TableHeaderCell>
<TableHeaderCell>{t('reviews.schema.fromVersion', { defaultValue: 'От версии' })}</TableHeaderCell>
<TableHeaderCell>{t('reviews.schema.maker', { defaultValue: 'Автор' })}</TableHeaderCell>
<TableHeaderCell>{t('reviews.schema.submittedAt', { defaultValue: 'Отправлен' })}</TableHeaderCell>
<TableHeaderCell align="right">{t('reviews.schema.action', { defaultValue: 'Действие' })}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((d) => (
<TableRow key={d.draftId}>
<TableCell>
<Link
to="/dictionaries/$name"
params={{ name: d.dictionaryName }}
className="text-accent hover:underline font-mono"
>
{d.dictionaryName}
</Link>
</TableCell>
<TableCell className="font-mono tabular-nums">v{d.branchedFromVersion}</TableCell>
<TableCell><UserCell uuid={d.makerId} /></TableCell>
<TableCell className="tabular-nums">
{d.submittedAt ? new Date(d.submittedAt).toLocaleString() : '—'}
</TableCell>
<TableCell align="right">
<Link
to="/dictionaries/$name"
params={{ name: d.dictionaryName }}
className="text-accent hover:underline"
>
{t('reviews.schema.open', { defaultValue: 'Открыть' })}
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Panel>
)
}