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')} +

+