feat(approval): bulk approve/reject в /reviews queue (Phase 4 polish)
Phase 4 last optional UX из design doc — bulk operations: - Multi-select column в reviewer queue (Checkbox per row + select-all header). Set<draftId> 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.
This commit is contained in:
@@ -54,6 +54,36 @@ i18n
|
|||||||
'reviews.action.approve': 'Approve',
|
'reviews.action.approve': 'Approve',
|
||||||
'reviews.action.reject': 'Reject',
|
'reviews.action.reject': 'Reject',
|
||||||
'reviews.action.cancel': 'Отмена',
|
'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.title': 'Согласование draft',
|
||||||
'reviews.drawer.maker': 'автор',
|
'reviews.drawer.maker': 'автор',
|
||||||
'reviews.drawer.submitted': 'отправлен',
|
'reviews.drawer.submitted': 'отправлен',
|
||||||
@@ -474,6 +504,30 @@ i18n
|
|||||||
'reviews.action.approve': 'Approve',
|
'reviews.action.approve': 'Approve',
|
||||||
'reviews.action.reject': 'Reject',
|
'reviews.action.reject': 'Reject',
|
||||||
'reviews.action.cancel': 'Cancel',
|
'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.title': 'Review draft',
|
||||||
'reviews.drawer.maker': 'maker',
|
'reviews.drawer.maker': 'maker',
|
||||||
'reviews.drawer.submitted': 'submitted',
|
'reviews.drawer.submitted': 'submitted',
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Drawer,
|
Drawer,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
LoadingBlock,
|
LoadingBlock,
|
||||||
|
Modal,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Panel,
|
Panel,
|
||||||
Table,
|
Table,
|
||||||
@@ -16,12 +18,13 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeaderCell,
|
TableHeaderCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
TextArea,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@nstart/ui'
|
} from '@nstart/ui'
|
||||||
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
|
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
|
||||||
import { useDraft, useLiveRecord, useReviewQueue } from '@/api/queries'
|
import { useDraft, useLiveRecord, useReviewQueue } from '@/api/queries'
|
||||||
import { useApproveDraft, useRejectDraft } from '@/api/mutations'
|
import { useApproveDraft, useRejectDraft } from '@/api/mutations'
|
||||||
import type { DraftOperation } from '@/api/client'
|
import type { DraftOperation, DraftResponse } from '@/api/client'
|
||||||
|
|
||||||
export const Route = createFileRoute('/reviews')({
|
export const Route = createFileRoute('/reviews')({
|
||||||
component: ReviewsPage,
|
component: ReviewsPage,
|
||||||
@@ -40,10 +43,97 @@ const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' =>
|
|||||||
* drawer открывается с side-by-side current vs proposed JSON + Approve /
|
* drawer открывается с side-by-side current vs proposed JSON + Approve /
|
||||||
* Reject actions. Reject reason required.
|
* 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() {
|
function ReviewsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queue = useReviewQueue(0, 50)
|
const queue = useReviewQueue(0, 50)
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
|
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
|
||||||
|
/** Phase 4: bulk approve/reject. Set<draftId> — стабильное id'шник. */
|
||||||
|
const [selection, setSelection] = useState<Set<string>>(new Set())
|
||||||
|
const [bulkRejectOpen, setBulkRejectOpen] = useState(false)
|
||||||
|
const [bulkRejectReason, setBulkRejectReason] = useState('')
|
||||||
|
const [bulkRunning, setBulkRunning] = useState(false)
|
||||||
|
const [bulkResult, setBulkResult] = useState<BulkResult | undefined>(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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -60,20 +150,98 @@ function ReviewsPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{bulkResult && (
|
||||||
|
<Alert
|
||||||
|
variant={bulkResult.failed.length === 0 ? 'success' : 'warning'}
|
||||||
|
title={t('reviews.bulk.resultTitle')}
|
||||||
|
action={
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setBulkResult(undefined)}>
|
||||||
|
{t('reviews.bulk.cancel')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ul className="space-y-1 text-2xs">
|
||||||
|
{bulkResult.approved.length > 0 && (
|
||||||
|
<li>
|
||||||
|
{t('reviews.bulk.approvedCount', { count: bulkResult.approved.length })}
|
||||||
|
: {bulkResult.approved.map((r) => r.bk).join(', ')}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{bulkResult.rejected.length > 0 && (
|
||||||
|
<li>
|
||||||
|
{t('reviews.bulk.rejectedCount', { count: bulkResult.rejected.length })}
|
||||||
|
: {bulkResult.rejected.map((r) => r.bk).join(', ')}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{bulkResult.failed.length > 0 && (
|
||||||
|
<li className="text-error">
|
||||||
|
{t('reviews.bulk.failedCount', { count: bulkResult.failed.length })}:
|
||||||
|
<ul className="ml-4 mt-1 list-disc">
|
||||||
|
{bulkResult.failed.map((f) => (
|
||||||
|
<li key={f.id}>
|
||||||
|
<span className="font-mono">{f.bk}</span>: {f.reason}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{queue.data && queue.data.totalElements === 0 && (
|
{queue.data && queue.data.totalElements === 0 && (
|
||||||
<EmptyState title={t('reviews.empty')} />
|
<EmptyState title={t('reviews.empty')} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{queue.data && queue.data.totalElements > 0 && (
|
{queue.data && queue.data.totalElements > 0 && (
|
||||||
<Panel>
|
<Panel>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3 gap-3">
|
||||||
<p className="text-2xs text-carbon/60">
|
<p className="text-2xs text-carbon/60">
|
||||||
{t('reviews.queueTotal', { count: queue.data.totalElements })}
|
{t('reviews.queueTotal', { count: queue.data.totalElements })}
|
||||||
|
{anySelected ? (
|
||||||
|
<span className="ml-2 text-ultramarain font-medium">
|
||||||
|
· {t('reviews.bulk.selectedCount', { count: selection.size })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
{anySelected && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={bulkRunning}
|
||||||
|
onClick={handleBulkApprove}
|
||||||
|
>
|
||||||
|
{t('reviews.bulk.approveSelected')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={bulkRunning}
|
||||||
|
onClick={() => setBulkRejectOpen(true)}
|
||||||
|
>
|
||||||
|
{t('reviews.bulk.rejectSelected')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelection(new Set())}
|
||||||
|
>
|
||||||
|
{t('reviews.bulk.clear')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHeaderCell>
|
||||||
|
<Checkbox
|
||||||
|
aria-label={t('reviews.bulk.selectAll')}
|
||||||
|
checked={allVisibleSelected}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
/>
|
||||||
|
</TableHeaderCell>
|
||||||
<TableHeaderCell>{t('reviews.col.dict')}</TableHeaderCell>
|
<TableHeaderCell>{t('reviews.col.dict')}</TableHeaderCell>
|
||||||
<TableHeaderCell>{t('reviews.col.bk')}</TableHeaderCell>
|
<TableHeaderCell>{t('reviews.col.bk')}</TableHeaderCell>
|
||||||
<TableHeaderCell>{t('reviews.col.op')}</TableHeaderCell>
|
<TableHeaderCell>{t('reviews.col.op')}</TableHeaderCell>
|
||||||
@@ -83,8 +251,15 @@ function ReviewsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{queue.data.items.map((d) => (
|
{items.map((d) => (
|
||||||
<TableRow key={d.id}>
|
<TableRow key={d.id} data-selected={selection.has(d.id)}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
aria-label={`${t('reviews.bulk.selectRow')} ${d.businessKey}`}
|
||||||
|
checked={selection.has(d.id)}
|
||||||
|
onChange={() => toggleSelect(d.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
to="/dictionaries/$name"
|
to="/dictionaries/$name"
|
||||||
@@ -122,6 +297,43 @@ function ReviewsPage() {
|
|||||||
draftId={selectedId}
|
draftId={selectedId}
|
||||||
onClose={() => setSelectedId(undefined)}
|
onClose={() => setSelectedId(undefined)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={bulkRejectOpen}
|
||||||
|
onClose={() => setBulkRejectOpen(false)}
|
||||||
|
title={t('reviews.bulk.rejectModalTitle', { count: selection.size })}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-2xs text-carbon/70">
|
||||||
|
{t('reviews.bulk.rejectModalDescription')}
|
||||||
|
</p>
|
||||||
|
<TextArea
|
||||||
|
label={t('reviews.bulk.rejectReasonLabel')}
|
||||||
|
value={bulkRejectReason}
|
||||||
|
onChange={(e) => setBulkRejectReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder={t('reviews.bulk.rejectReasonPlaceholder')}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setBulkRejectOpen(false)
|
||||||
|
setBulkRejectReason('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('reviews.bulk.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={!bulkRejectReason.trim() || bulkRunning}
|
||||||
|
onClick={handleBulkReject}
|
||||||
|
>
|
||||||
|
{t('reviews.bulk.confirmReject', { count: selection.size })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user