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.
*
*
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 = {
PENDING: 'На ревью',
APPROVED: 'Одобрено',
REJECTED: 'Отклонён',
WITHDRAWN: 'Отозван',
}
return (
{t(`reviews.mine.recordStatus.${status}`, { defaultValue: fallback[status] })}
)
}
/**
* 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 | null | undefined
proposed: Record | 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 (
)
}
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: " → 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(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 (
{draftQ.isLoading && }
{draftQ.error && (
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. */}
{/* Read-only decision banner — для всех terminal статусов. Показывает
reviewer'а + decision time + reviewer comment чтобы maker'у не
пришлось гадать "что произошло". */}
{!isPending && (
)
}
/**
* Schema review pool queue — global view всех \`review_pending\` schema
* draft'ов через {@code GET /admin/schema-reviews/pending}. Reviewer
* жмёт «Открыть» → переходит на dict detail page, banner+drawer
* автоматически подхватятся через {@code useDictActiveSchemaDraft}.
*
*
Скрывается если 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 (