361f3b431c
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.
510 lines
17 KiB
TypeScript
510 lines
17 KiB
TypeScript
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,
|
||
TableBody,
|
||
TableCell,
|
||
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, DraftResponse } from '@/api/client'
|
||
|
||
export const Route = createFileRoute('/reviews')({
|
||
component: ReviewsPage,
|
||
})
|
||
|
||
const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' => {
|
||
if (op === 'CREATE') return 'success'
|
||
if (op === 'CLOSE') return 'warning'
|
||
return 'info'
|
||
}
|
||
|
||
/**
|
||
* Approval Workflow v2 — reviewer queue (D3=A pool model).
|
||
*
|
||
* <p>Все pending drafts в ordinis FIFO ordered by submitted_at. Click row →
|
||
* 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<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 (
|
||
<div className="space-y-4">
|
||
<PageHeader
|
||
title={t('reviews.title')}
|
||
description={t('reviews.description')}
|
||
/>
|
||
|
||
{queue.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||
|
||
{queue.error && (
|
||
<Alert variant="error" title={t('error.failed')}>
|
||
{String(queue.error)}
|
||
</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 && (
|
||
<EmptyState title={t('reviews.empty')} />
|
||
)}
|
||
|
||
{queue.data && queue.data.totalElements > 0 && (
|
||
<Panel>
|
||
<div className="flex items-center justify-between mb-3 gap-3">
|
||
<p className="text-2xs text-carbon/60">
|
||
{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>
|
||
{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>
|
||
<Table>
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableHeaderCell>
|
||
<Checkbox
|
||
aria-label={t('reviews.bulk.selectAll')}
|
||
checked={allVisibleSelected}
|
||
onChange={toggleSelectAll}
|
||
/>
|
||
</TableHeaderCell>
|
||
<TableHeaderCell>{t('reviews.col.dict')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('reviews.col.bk')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('reviews.col.op')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('reviews.col.maker')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('reviews.col.submitted')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('reviews.col.actions')}</TableHeaderCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{items.map((d) => (
|
||
<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>
|
||
<Link
|
||
to="/dictionaries/$name"
|
||
params={{ name: d.dictionaryId }}
|
||
className="text-ultramarain hover:underline font-mono text-2xs"
|
||
>
|
||
{d.dictionaryId.slice(0, 8)}…
|
||
</Link>
|
||
</TableCell>
|
||
<TableCell className="font-mono text-2xs">{d.businessKey}</TableCell>
|
||
<TableCell>
|
||
<Badge variant={operationVariant(d.operation)}>{d.operation}</Badge>
|
||
</TableCell>
|
||
<TableCell className="font-mono text-2xs">{d.makerId}</TableCell>
|
||
<TableCell className="text-2xs text-carbon/70">
|
||
{new Date(d.submittedAt).toLocaleString()}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => setSelectedId(d.id)}
|
||
>
|
||
{t('reviews.action.review')}
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</Panel>
|
||
)}
|
||
|
||
<ReviewDrawer
|
||
draftId={selectedId}
|
||
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>
|
||
)
|
||
}
|
||
|
||
type DrawerProps = {
|
||
draftId: string | undefined
|
||
onClose: () => void
|
||
}
|
||
|
||
function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
||
const { t } = useTranslation()
|
||
const draftQ = useDraft(draftId)
|
||
const draft = draftQ.data
|
||
const liveQ = useLiveRecord(
|
||
draft ? draft.dictionaryId : undefined,
|
||
draft ? draft.businessKey : undefined,
|
||
)
|
||
const approveMut = useApproveDraft()
|
||
const rejectMut = useRejectDraft()
|
||
const [comment, setComment] = useState('')
|
||
|
||
const handleApprove = () => {
|
||
if (!draftId) return
|
||
approveMut.mutate(
|
||
{ id: draftId, comment: comment.trim() || undefined },
|
||
{
|
||
onSuccess: () => {
|
||
setComment('')
|
||
onClose()
|
||
},
|
||
},
|
||
)
|
||
}
|
||
|
||
const handleReject = () => {
|
||
if (!draftId) return
|
||
if (!comment.trim()) return // backend will 400, but fail fast in UI
|
||
rejectMut.mutate(
|
||
{ id: draftId, comment: comment.trim() },
|
||
{
|
||
onSuccess: () => {
|
||
setComment('')
|
||
onClose()
|
||
},
|
||
},
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Drawer
|
||
isOpen={Boolean(draftId)}
|
||
onClose={onClose}
|
||
title={t('reviews.drawer.title')}
|
||
side="right"
|
||
widthClassName="w-full max-w-3xl"
|
||
>
|
||
<div className="space-y-4">
|
||
{draftQ.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||
{draftQ.error && (
|
||
<Alert variant="error" title={t('error.failed')}>
|
||
{String(draftQ.error)}
|
||
</Alert>
|
||
)}
|
||
|
||
{draft && (
|
||
<>
|
||
<div className="flex flex-wrap items-center gap-2 text-2xs">
|
||
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
|
||
<span className="font-mono">{draft.businessKey}</span>
|
||
<span className="text-carbon/60">
|
||
{t('reviews.drawer.maker')}: {draft.makerId}
|
||
</span>
|
||
<span className="text-carbon/60">
|
||
{t('reviews.drawer.submitted')}:{' '}
|
||
{new Date(draft.submittedAt).toLocaleString()}
|
||
</span>
|
||
</div>
|
||
|
||
{draft.makerComment && (
|
||
<div className="px-3 py-2 rounded-sm border border-regolith bg-regolith/30 text-2xs">
|
||
<span className="text-carbon/60 uppercase tracking-label font-secondary">
|
||
{t('reviews.drawer.makerComment')}:
|
||
</span>{' '}
|
||
{draft.makerComment}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div>
|
||
<p className="text-2xs uppercase tracking-label text-carbon/60 mb-1">
|
||
{t('reviews.drawer.live')}
|
||
</p>
|
||
{liveQ.isLoading && <LoadingBlock size="sm" label={t('loading')} />}
|
||
{liveQ.data === null && (
|
||
<p className="text-2xs text-carbon/60 italic">
|
||
{t('reviews.drawer.noLive')}
|
||
</p>
|
||
)}
|
||
{liveQ.data && (
|
||
<pre className="bg-regolith/30 rounded p-2 text-2xs font-mono overflow-x-auto whitespace-pre-wrap">
|
||
{JSON.stringify(liveQ.data.data, null, 2)}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<p className="text-2xs uppercase tracking-label text-carbon/60 mb-1">
|
||
{t('reviews.drawer.proposed')}
|
||
</p>
|
||
{draft.operation === 'CLOSE' ? (
|
||
<p className="text-2xs text-aurora italic">
|
||
{t('reviews.drawer.closeNote')}
|
||
</p>
|
||
) : (
|
||
<pre className="bg-ultramarain/4 rounded p-2 text-2xs font-mono overflow-x-auto whitespace-pre-wrap">
|
||
{JSON.stringify(draft.data, null, 2)}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{(approveMut.error || rejectMut.error) && (
|
||
<Alert variant="error" title={t('error.failed')}>
|
||
{String(approveMut.error || rejectMut.error)}
|
||
</Alert>
|
||
)}
|
||
|
||
<div className="border-t border-regolith pt-3 space-y-2">
|
||
<label
|
||
htmlFor="review-comment"
|
||
className="text-2xs uppercase tracking-label text-carbon/70"
|
||
>
|
||
{t('reviews.drawer.comment')}
|
||
</label>
|
||
<TextInput
|
||
id="review-comment"
|
||
value={comment}
|
||
onChange={(e) => setComment(e.target.value)}
|
||
placeholder={t('reviews.drawer.commentPlaceholder')}
|
||
/>
|
||
<div className="flex items-center justify-end gap-2 pt-2">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={onClose}
|
||
disabled={approveMut.isPending || rejectMut.isPending}
|
||
>
|
||
{t('reviews.action.cancel')}
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
leftIcon={<XCircleIcon weight="bold" size={14} />}
|
||
onClick={handleReject}
|
||
disabled={!comment.trim() || rejectMut.isPending || approveMut.isPending}
|
||
loading={rejectMut.isPending}
|
||
>
|
||
{t('reviews.action.reject')}
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
leftIcon={<CheckCircleIcon weight="bold" size={14} />}
|
||
onClick={handleApprove}
|
||
disabled={approveMut.isPending || rejectMut.isPending}
|
||
loading={approveMut.isPending}
|
||
>
|
||
{t('reviews.action.approve')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</Drawer>
|
||
)
|
||
}
|