Files
mdm-ordinis/ordinis-admin-ui/src/routes/reviews.tsx
T
Zimin A.N. 361f3b431c 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.
2026-05-10 12:12:57 +03:00

510 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}