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) && (
-