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.bulk.approveSelected')}
setBulkRejectOpen(true)}
>
{t('reviews.bulk.rejectSelected')}
setSelection(new Set())}
>
{t('reviews.bulk.clear')}
)}
{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(d.id)}
>
{t('reviews.action.review')}
))}
)}
setSelectedId(undefined)}
/>
setBulkRejectOpen(false)}
title={t('reviews.bulk.rejectModalTitle', { count: selection.size })}
>
{t('reviews.bulk.rejectModalDescription')}
)
}
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 (
{draftQ.isLoading &&
}
{draftQ.error && (
{String(draftQ.error)}
)}
{draft && (
<>
{draft.operation}
{draft.businessKey}
{t('reviews.drawer.maker')}: {draft.makerId}
{t('reviews.drawer.submitted')}:{' '}
{new Date(draft.submittedAt).toLocaleString()}
{draft.makerComment && (
{t('reviews.drawer.makerComment')}:
{' '}
{draft.makerComment}
)}
{t('reviews.drawer.live')}
{liveQ.isLoading &&
}
{liveQ.data === null && (
{t('reviews.drawer.noLive')}
)}
{liveQ.data && (
{JSON.stringify(liveQ.data.data, null, 2)}
)}
{t('reviews.drawer.proposed')}
{draft.operation === 'CLOSE' ? (
{t('reviews.drawer.closeNote')}
) : (
{JSON.stringify(draft.data, null, 2)}
)}
{(approveMut.error || rejectMut.error) && (
{String(approveMut.error || rejectMut.error)}
)}
{t('reviews.drawer.comment')}
>
)}
)
}
/**
* Schema review pool queue — global view всех \`review_pending\` schema
* draft'ов через {@code GET /admin/schema-reviews/pending}. Reviewer
* жмёт «Открыть» → переходит на dict detail page, banner+drawer
* автоматически подхватятся через {@code useDictActiveSchemaDraft}.
*
* Скрывается если pool пуст — экономит вертикальное место на странице
* для основного record-review queue.
*/
function SchemaReviewQueuePanel() {
const { t } = useTranslation()
const queue = useSchemaReviewQueue(0, 20)
const items = queue.data?.items ?? []
if (queue.isLoading || items.length === 0) return null
return (
{t('reviews.schema.title', { defaultValue: 'Изменения схем' })}
{t('reviews.schema.queueTotal', {
count: queue.data?.total ?? items.length,
defaultValue: `${queue.data?.total ?? items.length} draft(s) ждут ревью`,
})}
{t('reviews.schema.label', { defaultValue: 'Schema workflow' })}
{t('reviews.schema.dictionary', { defaultValue: 'Словарь' })}
{t('reviews.schema.fromVersion', { defaultValue: 'От версии' })}
{t('reviews.schema.maker', { defaultValue: 'Автор' })}
{t('reviews.schema.submittedAt', { defaultValue: 'Отправлен' })}
{t('reviews.schema.action', { defaultValue: 'Действие' })}
{items.map((d) => (
{d.dictionaryName}
v{d.branchedFromVersion}
{d.makerId}
{d.submittedAt ? new Date(d.submittedAt).toLocaleString() : '—'}
{t('reviews.schema.open', { defaultValue: 'Открыть' })} →
))}
)
}