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, Tabs, TextArea, TextInput, QueryErrorState, } from '@/ui' import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react' import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } 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). * *

Все 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 }[] } type ReviewsTab = 'records' | 'schemas' function ReviewsPage() { const { t } = useTranslation() const queue = useReviewQueue(0, 50) const schemaQueue = useSchemaReviewQueue(0, 50) const recordsCount = queue.data?.totalElements ?? 0 const schemasCount = schemaQueue.data?.total ?? 0 // Tab дефолтит на records (старее, чаще). Если schema queue не пустой // а record queue пустой — переключаемся на schemas чтобы reviewer'у не // пришлось кликать дважды. const [activeTab, setActiveTab] = useState('records') // Auto-switch один раз когда queries loaded и records=0/schemas>0. useMemo(() => { if (recordsCount === 0 && schemasCount > 0 && activeTab === 'records') { setActiveTab('schemas') } // eslint-disable-next-line react-hooks/exhaustive-deps }, [recordsCount, schemasCount]) 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 (

setActiveTab(v as ReviewsTab)} items={[ { id: 'records', label: t('reviews.tab.records', { defaultValue: 'Записи' }), count: recordsCount > 0 ? ( {recordsCount} ) : undefined, }, { id: 'schemas', label: t('reviews.tab.schemas', { defaultValue: 'Схемы' }), count: schemasCount > 0 ? ( {schemasCount} ) : undefined, }, ]} /> {activeTab === 'schemas' && ( <> {schemaQueue.isLoading && } {schemasCount === 0 && !schemaQueue.isLoading && ( )} )} {activeTab === 'records' && queue.isLoading && ( )} {activeTab === 'records' && queue.error && ( queue.refetch()} /> )} {activeTab === 'records' && 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}
    • ))}
  • )}
)} {activeTab === 'records' && queue.data && queue.data.totalElements === 0 && ( )} {activeTab === 'records' && 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')} {t('reviews.col.maker')} {t('reviews.col.submitted')} {t('reviews.col.actions')} {items.map((d) => ( toggleSelect(d.id)} /> {d.dictionaryId.slice(0, 8)}… {d.businessKey} {d.operation} {d.makerId} {new Date(d.submittedAt).toLocaleString()} ))}
)} setSelectedId(undefined)} /> setBulkRejectOpen(false)} title={t('reviews.bulk.rejectModalTitle', { count: selection.size })} >

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