import { useEffect, 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 axios from 'axios' import { useAuth } from '@/auth/useAuth' import { useIsAdmin } from '@/auth/usePermissions' import { ArrowCounterClockwiseIcon, CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react' import { useDictionaries, useDraft, useLiveRecord, useMyDrafts, useMySchemaDrafts, useReviewQueue, useSchemaReviewQueue, } from '@/api/queries' import { useApproveDraft, useRejectDraft, useWithdrawDraft } from '@/api/mutations' import type { DraftOperation, DraftResponse, DraftStatus } from '@/api/client' import { UserCell } from '@/lib/useUserDisplay' import { shallowDiffSummary, isEmptyDiffSummary } from '@/lib/diff' import { humanizeBulkReason } from '@/lib/bulkReason' import { MyDraftsPanel, classifyRecordStatus, classifySchemaStatus, } from '@/components/reviews/MyDraftsPanel' import { JsonView } from '@/components/JsonView' 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' } /** * Record draft status badge — используется в drawer header'е, чтобы maker * сразу видел "в каком состоянии мой draft" без скролла к decision banner. * *

Visual mapping повторяет {@link MyDraftsPanel} badge'и для consistency: * PENDING=info, APPROVED=success, REJECTED=danger, WITHDRAWN=default. */ function DrawerStatusBadge({ status }: { status: DraftStatus }) { const { t } = useTranslation() const variant: 'info' | 'success' | 'danger' | 'default' = (() => { switch (status) { case 'PENDING': return 'info' case 'APPROVED': return 'success' case 'REJECTED': return 'danger' case 'WITHDRAWN': return 'default' } })() const fallback: Record = { PENDING: 'На ревью', APPROVED: 'Одобрено', REJECTED: 'Отклонён', WITHDRAWN: 'Отозван', } return ( {t(`reviews.mine.recordStatus.${status}`, { defaultValue: fallback[status] })} ) } /** * Diff summary chips for the ReviewDrawer — "+N added (fields), −M removed, * ~K changed" above the side-by-side JSON panes. Reviewer sees the gist * without eyeball-diffing long records. * * Backed by `shallowDiffSummary` from `@/lib/diff` (extracted there during * the 2026-05-14 eng review of MR !186 for reuse — RecordHistoryDrawer and * audit row summaries will plug into the same helper). Memo'd against the * three actual inputs so it doesn't recompute on unrelated state changes * (textarea typing, drawer resize, etc.). */ function DraftDiffSummary({ live, proposed, operation, }: { live: Record | null | undefined proposed: Record | null | undefined operation: DraftOperation }) { const summary = useMemo(() => { // CLOSE op has no proposed payload by design — skip without computing. if (operation === 'CLOSE') return null return shallowDiffSummary(live, proposed) }, [live, proposed, operation]) if (!summary || isEmptyDiffSummary(summary)) return null return (

{summary.added.length > 0 && ( + {summary.added.length} ({summary.added.join(', ')}) )} {summary.removed.length > 0 && ( {summary.removed.length} ({summary.removed.join(', ')}) )} {summary.changed.length > 0 && ( ~ {summary.changed.length} ({summary.changed.join(', ')}) )}
) } /** * 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' | 'mine' function ReviewsPage() { const { t } = useTranslation() const queue = useReviewQueue(0, 50) const schemaQueue = useSchemaReviewQueue(0, 50) // DraftResponse несёт только dictionaryId (UUID) — резолвим в name через // global dictionaries list (кешируется TanStack Query, ~free). Fallback на // UUID-prefix когда dict недоступен (scope-hide / deleted). const dictionaries = useDictionaries() const dictNameById = useMemo(() => { const map = new Map() for (const d of dictionaries.data ?? []) map.set(d.id, d.name) return map }, [dictionaries.data]) // Counts для "Мои" tab badge — sum of attention-needing drafts (pending + // WIP). Recompute дешёвый (linear over ≤100 items), refetch interval 30s // делает badge самозаживляющим без manual invalidation. const myDrafts = useMyDrafts(0, 50) const mySchemaDrafts = useMySchemaDrafts(0, 50) const recordsCount = queue.data?.totalElements ?? 0 const schemasCount = schemaQueue.data?.totalElements ?? 0 const myActiveCount = useMemo(() => { const records = (myDrafts.data?.items ?? []).filter( (r) => classifyRecordStatus(r.status) === 'pending', ).length const schemas = (mySchemaDrafts.data?.items ?? []).filter((s) => { const b = classifySchemaStatus(s.status) return b === 'pending' || b === 'wip' }).length return records + schemas }, [myDrafts.data, mySchemaDrafts.data]) // 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, }, { id: 'mine', label: t('reviews.tab.mine', { defaultValue: 'Мои' }), count: myActiveCount > 0 ? ( {myActiveCount} ) : undefined, }, ]} /> {activeTab === 'mine' && ( setSelectedId(id)} /> )} {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}: {humanizeBulkReason(f.reason)}
    • ))}
  • )}
)} {activeTab === 'records' && queue.data && queue.data.totalElements === 0 && ( } title={t('reviews.empty')} description={t('reviews.emptyDescription', { defaultValue: 'Очередь чистая. Прошлые решения можно посмотреть в журнале аудита.', })} action={ {t('reviews.emptyAuditLink', { defaultValue: 'Открыть /audit' })} → } /> )} {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)} /> {(() => { const dictName = dictNameById.get(d.dictionaryId) if (dictName) { return ( {dictName} ) } // Dict скрыт scope-фильтром или удалён — UUID-prefix // как fallback, без link'а (вёл бы в 404). return ( {d.dictionaryId.slice(0, 8)}… ) })()} {d.businessKey} {d.operation} {new Date(d.submittedAt).toLocaleString()} ))}
)} setSelectedId(undefined)} /> setBulkRejectOpen(false)} title={t('reviews.bulk.rejectModalTitle', { count: selection.size })} >

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