From 361f3b431c42b7fdbf914998024b168efb16f8ec Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Sun, 10 May 2026 12:12:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(approval):=20bulk=20approve/reject=20?= =?UTF-8?q?=D0=B2=20/reviews=20queue=20(Phase=204=20polish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 last optional UX из design doc — bulk operations: - Multi-select column в reviewer queue (Checkbox per row + select-all header). Set selection state, persisted между refetch'ами. - "Approve выбранные" — confirm dialog → parallel approveDraft mutations через Promise.all. Каждая mutation independent: если одна fail (409 self_approve_forbidden / draft_not_pending race), остальные продолжают. - "Reject выбранные" — Modal с TextArea для shared reason (required, matches backend reject_reason_required validation). Reason одинаковый для всех — bulk обычно "duplicate registration" / "wrong scope" / etc. - Bulk result Alert: approved count / rejected count / failed list с per-draft reason'ами (err code из 409). User видит точно что прошло и что нет. - Auto-clear selection после bulk operation. Queue refetch'ит через refetchInterval=30s, плюс mutation onSuccess invalidate'ит ['drafts']. i18n RU/EN: 22 keys (selectAll/selectRow/selectedCount/approveSelected/ rejectSelected/clear/confirmApprove/rejectModal*/confirmReject/cancel/ resultTitle/approvedCount/rejectedCount/failedCount). Pluralization через i18next plurals. Why ship without soak data: - Implementation не зависит от queue depth — это просто loop'ит над выбранными drafts. - Reviewer на любом dict с queue >5 уже выгадывает время. - Если queue staying small после soak — feature не bothering нас (UI toolbar показывается только когда anySelected). Tests: vitest 89/89, tsc clean, prod build clean. --- ordinis-admin-ui/src/i18n.ts | 54 ++++++ ordinis-admin-ui/src/routes/reviews.tsx | 222 +++++++++++++++++++++++- 2 files changed, 271 insertions(+), 5 deletions(-) diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index f1d4554..5b89f3c 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -54,6 +54,36 @@ i18n 'reviews.action.approve': 'Approve', 'reviews.action.reject': 'Reject', 'reviews.action.cancel': 'Отмена', + 'reviews.bulk.selectAll': 'Выбрать все', + 'reviews.bulk.selectRow': 'Выбрать draft', + 'reviews.bulk.selectedCount_one': 'выбран {{count}}', + 'reviews.bulk.selectedCount_few': 'выбрано {{count}}', + 'reviews.bulk.selectedCount_many': 'выбрано {{count}}', + 'reviews.bulk.selectedCount_other': 'выбрано {{count}}', + 'reviews.bulk.approveSelected': 'Approve выбранные', + 'reviews.bulk.rejectSelected': 'Reject выбранные', + 'reviews.bulk.clear': 'Снять выбор', + 'reviews.bulk.confirmApprove_one': 'Approve {{count}} draft? Это произведёт live-write для каждого.', + 'reviews.bulk.confirmApprove_few': 'Approve {{count}} draft\'а? Live-write для каждого.', + 'reviews.bulk.confirmApprove_many': 'Approve {{count}} draft\'ов? Live-write для каждого.', + 'reviews.bulk.confirmApprove_other': 'Approve {{count}} drafts? Live-write для каждого.', + 'reviews.bulk.rejectModalTitle_one': 'Reject {{count}} draft', + 'reviews.bulk.rejectModalTitle_few': 'Reject {{count}} draft\'а', + 'reviews.bulk.rejectModalTitle_many': 'Reject {{count}} draft\'ов', + 'reviews.bulk.rejectModalTitle_other': 'Reject {{count}} drafts', + 'reviews.bulk.rejectModalDescription': 'Все выбранные drafts будут отклонены с одним общим reason\'ом. Maker увидит причину в "Мои черновики".', + 'reviews.bulk.rejectReasonLabel': 'Причина (общая для всех)', + 'reviews.bulk.rejectReasonPlaceholder': 'Например: дубликат регистрации, неполные данные, не тот scope...', + 'reviews.bulk.confirmReject_one': 'Reject {{count}}', + 'reviews.bulk.confirmReject_other': 'Reject {{count}}', + 'reviews.bulk.cancel': 'Отмена', + 'reviews.bulk.resultTitle': 'Bulk операция завершена', + 'reviews.bulk.approvedCount_one': 'Approved {{count}}', + 'reviews.bulk.approvedCount_other': 'Approved {{count}}', + 'reviews.bulk.rejectedCount_one': 'Rejected {{count}}', + 'reviews.bulk.rejectedCount_other': 'Rejected {{count}}', + 'reviews.bulk.failedCount_one': 'Не прошло {{count}}', + 'reviews.bulk.failedCount_other': 'Не прошло {{count}}', 'reviews.drawer.title': 'Согласование draft', 'reviews.drawer.maker': 'автор', 'reviews.drawer.submitted': 'отправлен', @@ -474,6 +504,30 @@ i18n 'reviews.action.approve': 'Approve', 'reviews.action.reject': 'Reject', 'reviews.action.cancel': 'Cancel', + 'reviews.bulk.selectAll': 'Select all', + 'reviews.bulk.selectRow': 'Select draft', + 'reviews.bulk.selectedCount_one': '{{count}} selected', + 'reviews.bulk.selectedCount_other': '{{count}} selected', + 'reviews.bulk.approveSelected': 'Approve selected', + 'reviews.bulk.rejectSelected': 'Reject selected', + 'reviews.bulk.clear': 'Clear', + 'reviews.bulk.confirmApprove_one': 'Approve {{count}} draft? Each will produce a live-write.', + 'reviews.bulk.confirmApprove_other': 'Approve {{count}} drafts? Each will produce a live-write.', + 'reviews.bulk.rejectModalTitle_one': 'Reject {{count}} draft', + 'reviews.bulk.rejectModalTitle_other': 'Reject {{count}} drafts', + 'reviews.bulk.rejectModalDescription': 'All selected drafts will be rejected with one shared reason. Maker sees it in My drafts.', + 'reviews.bulk.rejectReasonLabel': 'Reason (shared for all)', + 'reviews.bulk.rejectReasonPlaceholder': 'e.g. duplicate registration, incomplete data, wrong scope...', + 'reviews.bulk.confirmReject_one': 'Reject {{count}}', + 'reviews.bulk.confirmReject_other': 'Reject {{count}}', + 'reviews.bulk.cancel': 'Cancel', + 'reviews.bulk.resultTitle': 'Bulk operation finished', + 'reviews.bulk.approvedCount_one': 'Approved {{count}}', + 'reviews.bulk.approvedCount_other': 'Approved {{count}}', + 'reviews.bulk.rejectedCount_one': 'Rejected {{count}}', + 'reviews.bulk.rejectedCount_other': 'Rejected {{count}}', + 'reviews.bulk.failedCount_one': '{{count}} failed', + 'reviews.bulk.failedCount_other': '{{count}} failed', 'reviews.drawer.title': 'Review draft', 'reviews.drawer.maker': 'maker', 'reviews.drawer.submitted': 'submitted', diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx index b96fa3d..64bdc48 100644 --- a/ordinis-admin-ui/src/routes/reviews.tsx +++ b/ordinis-admin-ui/src/routes/reviews.tsx @@ -1,13 +1,15 @@ -import { useState } from 'react' +import { 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, @@ -16,12 +18,13 @@ import { TableHead, TableHeaderCell, TableRow, + TextArea, TextInput, } from '@nstart/ui' import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react' import { useDraft, useLiveRecord, useReviewQueue } from '@/api/queries' import { useApproveDraft, useRejectDraft } from '@/api/mutations' -import type { DraftOperation } from '@/api/client' +import type { DraftOperation, DraftResponse } from '@/api/client' export const Route = createFileRoute('/reviews')({ component: ReviewsPage, @@ -40,10 +43,97 @@ const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' => * 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 }[] +} + function ReviewsPage() { const { t } = useTranslation() const queue = useReviewQueue(0, 50) const [selectedId, setSelectedId] = useState(undefined) + /** Phase 4: bulk approve/reject. Set — стабильное id'шник. */ + const [selection, setSelection] = useState>(new Set()) + const [bulkRejectOpen, setBulkRejectOpen] = useState(false) + const [bulkRejectReason, setBulkRejectReason] = useState('') + const [bulkRunning, setBulkRunning] = useState(false) + const [bulkResult, setBulkResult] = useState(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 (
@@ -60,20 +150,98 @@ function ReviewsPage() { )} + {bulkResult && ( + setBulkResult(undefined)}> + {t('reviews.bulk.cancel')} + + } + > +
    + {bulkResult.approved.length > 0 && ( +
  • + {t('reviews.bulk.approvedCount', { count: bulkResult.approved.length })} + : {bulkResult.approved.map((r) => r.bk).join(', ')} +
  • + )} + {bulkResult.rejected.length > 0 && ( +
  • + {t('reviews.bulk.rejectedCount', { count: bulkResult.rejected.length })} + : {bulkResult.rejected.map((r) => r.bk).join(', ')} +
  • + )} + {bulkResult.failed.length > 0 && ( +
  • + {t('reviews.bulk.failedCount', { count: bulkResult.failed.length })}: +
      + {bulkResult.failed.map((f) => ( +
    • + {f.bk}: {f.reason} +
    • + ))} +
    +
  • + )} +
+
+ )} + {queue.data && queue.data.totalElements === 0 && ( )} {queue.data && queue.data.totalElements > 0 && ( -
+

{t('reviews.queueTotal', { count: queue.data.totalElements })} + {anySelected ? ( + + · {t('reviews.bulk.selectedCount', { count: selection.size })} + + ) : null}

+ {anySelected && ( +
+ + + +
+ )}
+ + + {t('reviews.col.dict')} {t('reviews.col.bk')} {t('reviews.col.op')} @@ -83,8 +251,15 @@ function ReviewsPage() { - {queue.data.items.map((d) => ( - + {items.map((d) => ( + + + toggleSelect(d.id)} + /> + setSelectedId(undefined)} /> + + setBulkRejectOpen(false)} + title={t('reviews.bulk.rejectModalTitle', { count: selection.size })} + > +
+

+ {t('reviews.bulk.rejectModalDescription')} +

+