From 94360681b6d6412d9d0ae4c5dd4b69600274a034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Wed, 13 May 2026 16:41:05 +0000 Subject: [PATCH] =?UTF-8?q?fix(reviews):=20friendly=20error=20=D0=B2=20Rev?= =?UTF-8?q?iewDrawer=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20AxiosError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ordinis-admin-ui/src/routes/reviews.tsx | 78 +++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx index 836d40c..7b8b949 100644 --- a/ordinis-admin-ui/src/routes/reviews.tsx +++ b/ordinis-admin-ui/src/routes/reviews.tsx @@ -23,6 +23,7 @@ import { TextInput, QueryErrorState, } from '@/ui' +import axios from 'axios' import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react' import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } from '@/api/queries' import { useApproveDraft, useRejectDraft } from '@/api/mutations' @@ -402,6 +403,69 @@ type DrawerProps = { 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 draftQ = useDraft(draftId) @@ -513,11 +577,15 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) { - {(approveMut.error || rejectMut.error) && ( - - {String(approveMut.error || rejectMut.error)} - - )} + {(approveMut.error || rejectMut.error) && (() => { + const err = approveMut.error || rejectMut.error + const info = extractReviewError(err) + return ( + + {info.body} + + ) + })()}