1123 lines
44 KiB
TypeScript
1123 lines
44 KiB
TypeScript
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.
|
||
*
|
||
* <p>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<DraftStatus, string> = {
|
||
PENDING: 'На ревью',
|
||
APPROVED: 'Одобрено',
|
||
REJECTED: 'Отклонён',
|
||
WITHDRAWN: 'Отозван',
|
||
}
|
||
return (
|
||
<Badge variant={variant}>
|
||
{t(`reviews.mine.recordStatus.${status}`, { defaultValue: fallback[status] })}
|
||
</Badge>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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<string, unknown> | null | undefined
|
||
proposed: Record<string, unknown> | 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 (
|
||
<div className="flex flex-wrap items-center gap-3 text-mono text-cap tabular-nums">
|
||
{summary.added.length > 0 && (
|
||
<span className="inline-flex items-center gap-1 text-aurora">
|
||
<span className="font-bold">+</span>
|
||
<span>{summary.added.length}</span>
|
||
<span className="text-mute lowercase">({summary.added.join(', ')})</span>
|
||
</span>
|
||
)}
|
||
{summary.removed.length > 0 && (
|
||
<span className="inline-flex items-center gap-1 text-danger">
|
||
<span className="font-bold">−</span>
|
||
<span>{summary.removed.length}</span>
|
||
<span className="text-mute lowercase">({summary.removed.join(', ')})</span>
|
||
</span>
|
||
)}
|
||
{summary.changed.length > 0 && (
|
||
<span className="inline-flex items-center gap-1 text-warn">
|
||
<span className="font-bold">~</span>
|
||
<span>{summary.changed.length}</span>
|
||
<span className="text-mute lowercase">({summary.changed.join(', ')})</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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 }[]
|
||
}
|
||
|
||
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<string, string>()
|
||
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<ReviewsTab>('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<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')}
|
||
/>
|
||
|
||
<Tabs
|
||
value={activeTab}
|
||
onValueChange={(v) => setActiveTab(v as ReviewsTab)}
|
||
items={[
|
||
{
|
||
id: 'records',
|
||
label: t('reviews.tab.records', { defaultValue: 'Записи' }),
|
||
count:
|
||
recordsCount > 0 ? (
|
||
<Badge variant="info">{recordsCount}</Badge>
|
||
) : undefined,
|
||
},
|
||
{
|
||
id: 'schemas',
|
||
label: t('reviews.tab.schemas', { defaultValue: 'Схемы' }),
|
||
count:
|
||
schemasCount > 0 ? (
|
||
<Badge variant="info">{schemasCount}</Badge>
|
||
) : undefined,
|
||
},
|
||
{
|
||
id: 'mine',
|
||
label: t('reviews.tab.mine', { defaultValue: 'Мои' }),
|
||
count:
|
||
myActiveCount > 0 ? (
|
||
<Badge variant="info">{myActiveCount}</Badge>
|
||
) : undefined,
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{activeTab === 'mine' && (
|
||
<MyDraftsPanel onRecordClick={(id) => setSelectedId(id)} />
|
||
)}
|
||
|
||
{activeTab === 'schemas' && (
|
||
<>
|
||
{schemaQueue.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||
<SchemaReviewQueuePanel />
|
||
{schemasCount === 0 && !schemaQueue.isLoading && (
|
||
<EmptyState
|
||
title={t('reviews.schema.empty', {
|
||
defaultValue: 'Очередь пуста',
|
||
})}
|
||
description={t('reviews.schema.emptyDescription', {
|
||
defaultValue: 'Схемы на ревью появятся здесь.',
|
||
})}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{activeTab === 'records' && queue.isLoading && (
|
||
<LoadingBlock size="md" label={t('loading')} />
|
||
)}
|
||
|
||
{activeTab === 'records' && queue.error && (
|
||
<QueryErrorState error={queue.error} onRetry={() => queue.refetch()} />
|
||
)}
|
||
|
||
{activeTab === 'records' && 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-cell">
|
||
{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>: {humanizeBulkReason(f.reason)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</li>
|
||
)}
|
||
</ul>
|
||
</Alert>
|
||
)}
|
||
|
||
{activeTab === 'records' && queue.data && queue.data.totalElements === 0 && (
|
||
<EmptyState
|
||
icon={<CheckCircleIcon weight="duotone" size={32} />}
|
||
title={t('reviews.empty')}
|
||
description={t('reviews.emptyDescription', {
|
||
defaultValue:
|
||
'Очередь чистая. Прошлые решения можно посмотреть в журнале аудита.',
|
||
})}
|
||
action={
|
||
<Link to="/audit" className="text-accent hover:underline text-cell">
|
||
{t('reviews.emptyAuditLink', { defaultValue: 'Открыть /audit' })} →
|
||
</Link>
|
||
}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === 'records' && queue.data && queue.data.totalElements > 0 && (
|
||
<Panel>
|
||
<div className="flex items-center justify-between mb-3 gap-3">
|
||
<p className="text-cell text-mute">
|
||
{t('reviews.queueTotal', { count: queue.data.totalElements })}
|
||
{anySelected ? (
|
||
<span className="ml-2 text-accent 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>
|
||
{(() => {
|
||
const dictName = dictNameById.get(d.dictionaryId)
|
||
if (dictName) {
|
||
return (
|
||
<Link
|
||
to="/dictionaries/$name"
|
||
params={{ name: dictName }}
|
||
className="text-mono text-accent hover:underline"
|
||
>
|
||
{dictName}
|
||
</Link>
|
||
)
|
||
}
|
||
// Dict скрыт scope-фильтром или удалён — UUID-prefix
|
||
// как fallback, без link'а (вёл бы в 404).
|
||
return (
|
||
<span className="text-mono text-mute" title={d.dictionaryId}>
|
||
{d.dictionaryId.slice(0, 8)}…
|
||
</span>
|
||
)
|
||
})()}
|
||
</TableCell>
|
||
<TableCell className="text-mono">{d.businessKey}</TableCell>
|
||
<TableCell>
|
||
<Badge variant={operationVariant(d.operation)}>{d.operation}</Badge>
|
||
</TableCell>
|
||
<TableCell><UserCell uuid={d.makerId} /></TableCell>
|
||
<TableCell className="text-cell text-ink-2">
|
||
{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-cell text-ink-2">
|
||
{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
|
||
}
|
||
|
||
/**
|
||
* Превращает Axios error в human-readable title + body. Mapping для
|
||
* известных backend кодов (см. {@code OrdinisException.conflict}):
|
||
* - self_approve_forbidden — maker = reviewer (D3 maker-checker invariant)
|
||
* - draft_not_pending — кто-то уже approve/reject'нул этот draft
|
||
* - forbidden_role — у actor'а нет нужной workflow роли (Phase 1d enforcement)
|
||
*
|
||
* Default fallback — backend message + статус код. Сырой "AxiosError ... 409"
|
||
* больше не показывается.
|
||
*/
|
||
function extractReviewError(err: unknown): { title: string; body: string } {
|
||
if (!err) return { title: 'Ошибка', body: '' }
|
||
if (axios.isAxiosError(err)) {
|
||
const status = err.response?.status
|
||
const data = err.response?.data as
|
||
| { code?: string; message?: string; error?: string }
|
||
| undefined
|
||
const code = data?.code
|
||
const msg = data?.message ?? data?.error ?? err.message
|
||
|
||
if (code === 'self_approve_forbidden') {
|
||
return {
|
||
title: 'Нельзя одобрить свой собственный черновик',
|
||
body:
|
||
'Maker-checker invariant: тот, кто создал draft, не может его approve. Попроси другого reviewer\'а из пула.',
|
||
}
|
||
}
|
||
if (code === 'self_reject_forbidden') {
|
||
return {
|
||
title: 'Нельзя отклонить свой собственный черновик',
|
||
body:
|
||
'Тот, кто создал draft, не может его reject. Если передумал — используй Отозвать (withdraw).',
|
||
}
|
||
}
|
||
if (code === 'draft_not_pending') {
|
||
return {
|
||
title: 'Draft уже не pending',
|
||
body: 'Кто-то уже принял решение по этому draft\'у. Обнови очередь.',
|
||
}
|
||
}
|
||
if (code === 'forbidden_role') {
|
||
return {
|
||
title: 'Недостаточно прав',
|
||
body: msg || 'У вас нет нужной workflow role для этого действия.',
|
||
}
|
||
}
|
||
if (code === 'reject_reason_required') {
|
||
return {
|
||
title: 'Укажите причину отклонения',
|
||
body: 'Поле «Комментарий ревьюера» обязательно для reject (для compliance).',
|
||
}
|
||
}
|
||
return {
|
||
title: `Ошибка ${status ?? ''}`.trim(),
|
||
body: msg,
|
||
}
|
||
}
|
||
return {
|
||
title: 'Ошибка',
|
||
body: err instanceof Error ? err.message : String(err),
|
||
}
|
||
}
|
||
|
||
function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
||
const { t } = useTranslation()
|
||
const auth = useAuth()
|
||
const currentSub = auth.user?.profile?.sub
|
||
const draftQ = useDraft(draftId)
|
||
const draft = draftQ.data
|
||
// DraftResponse несёт только dictionaryId (UUID); /api/v1/dictionaries/{name}/...
|
||
// ожидает имя словаря. Резолвим через useDictionaries (TanStack кеш).
|
||
// До этого useLiveRecord(draft.dictionaryId, ...) валился с 404
|
||
// "Dictionary not found: <uuid>" → live-блок drawer'а оставался пустым.
|
||
const dictionariesQ = useDictionaries()
|
||
const dictName = useMemo(
|
||
() => dictionariesQ.data?.find((d) => d.id === draft?.dictionaryId)?.name,
|
||
[dictionariesQ.data, draft?.dictionaryId],
|
||
)
|
||
// CREATE-черновик не имеет live-записи по определению — не пробуем её
|
||
// загружать (GET /dictionaries/{name}/records/{key} → 404, красный шум в
|
||
// консоли на каждый CREATE-draft). Для UPDATE/CLOSE — грузим как раньше.
|
||
const isCreateDraft = draft?.operation === 'CREATE'
|
||
const liveQ = useLiveRecord(
|
||
draft && !isCreateDraft ? dictName : undefined,
|
||
draft && !isCreateDraft ? draft.businessKey : undefined,
|
||
)
|
||
const approveMut = useApproveDraft()
|
||
const rejectMut = useRejectDraft()
|
||
const withdrawMut = useWithdrawDraft()
|
||
const [comment, setComment] = useState('')
|
||
/** Inline error для reject reason — show when reviewer кликнул Reject с
|
||
* пустым comment'ом. Реально cleared автоматически когда user печатает. */
|
||
const [rejectError, setRejectError] = useState<string | null>(null)
|
||
/** Brief success affordance после approve/reject mutation — drawer
|
||
* остаётся 600ms с зелёной/красной полосой, чтобы reviewer почувствовал
|
||
* что click registered. Без этого drawer закрывается мгновенно, click
|
||
* feedback теряется. */
|
||
const [decisionFlash, setDecisionFlash] = useState<'approve' | 'reject' | null>(null)
|
||
|
||
// Reset state когда reviewer переключается на другой draft без закрытия
|
||
// drawer'а (click row → open new draft в already-open drawer). Раньше
|
||
// {comment, rejectError} оставались от previous draft, и mutation .error
|
||
// объекты от approveMut/rejectMut/withdrawMut held conflict banners
|
||
// ('реверсное состояние draft', '409', etc.) из predыдущей попытки —
|
||
// показывались на форме нового draft (observed bug).
|
||
useEffect(() => {
|
||
setComment('')
|
||
setRejectError(null)
|
||
setDecisionFlash(null)
|
||
approveMut.reset()
|
||
rejectMut.reset()
|
||
withdrawMut.reset()
|
||
// approveMut/rejectMut/withdrawMut references stable между renders
|
||
// (TanStack mutation hook), но lint требует exhaustive deps. .reset
|
||
// — stable function, безопасно включить.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [draftId])
|
||
|
||
// Identity gates — определяют что показать в footer'е drawer'а.
|
||
//
|
||
// 1. `isPending` = false: terminal draft (APPROVED/REJECTED/WITHDRAWN).
|
||
// Никаких actions — read-only diff + decision banner.
|
||
// 2. `isMineAndPending`: maker открыл свой собственный pending draft из
|
||
// "Мои" таба. Для regular maker'а Approve self запрещён backend'ом
|
||
// (self_approve_forbidden), показываем Withdraw. Admin override
|
||
// (MR !280): backend разрешает self-approve если у actor'а admin
|
||
// роль → frontend тоже снимает блокировку, иначе UI скрывает кнопку
|
||
// которую backend бы пропустил.
|
||
// 3. Иначе — reviewer flow: approve/reject как было.
|
||
const isAdmin = useIsAdmin()
|
||
const isPending = draft?.status === 'PENDING'
|
||
const isMineAndPending =
|
||
isPending && Boolean(currentSub) && draft?.makerId === currentSub && !isAdmin
|
||
|
||
const handleApprove = () => {
|
||
if (!draftId) return
|
||
approveMut.mutate(
|
||
{ id: draftId, comment: comment.trim() || undefined },
|
||
{
|
||
onSuccess: () => {
|
||
setDecisionFlash('approve')
|
||
// Hold drawer 600ms так чтобы reviewer увидел зелёную полосу;
|
||
// потом reset state и close. Без флэша approve было instant.
|
||
setTimeout(() => {
|
||
setComment('')
|
||
setDecisionFlash(null)
|
||
onClose()
|
||
}, 600)
|
||
},
|
||
},
|
||
)
|
||
}
|
||
|
||
const handleReject = () => {
|
||
if (!draftId) return
|
||
if (!comment.trim()) {
|
||
// Раньше return был silent — reviewer кликал Reject и ничего не
|
||
// происходило. Backend всё равно вернёт 400 missing_reject_reason,
|
||
// но user-friendly inline error лучше.
|
||
setRejectError(
|
||
t('reviews.drawer.rejectReasonRequired', {
|
||
defaultValue:
|
||
'Укажите причину отклонения — comment обязателен для reject (compliance).',
|
||
}),
|
||
)
|
||
return
|
||
}
|
||
setRejectError(null)
|
||
rejectMut.mutate(
|
||
{ id: draftId, comment: comment.trim() },
|
||
{
|
||
onSuccess: () => {
|
||
setDecisionFlash('reject')
|
||
setTimeout(() => {
|
||
setComment('')
|
||
setDecisionFlash(null)
|
||
onClose()
|
||
}, 600)
|
||
},
|
||
},
|
||
)
|
||
}
|
||
|
||
const handleWithdraw = () => {
|
||
if (!draftId) return
|
||
if (!confirm(t('reviews.action.withdrawConfirm', {
|
||
defaultValue: 'Отозвать свой черновик? После этого он попадёт в Decided.',
|
||
}))) return
|
||
withdrawMut.mutate(draftId, {
|
||
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 && (
|
||
<QueryErrorState error={draftQ.error} onRetry={() => draftQ.refetch()} />
|
||
)}
|
||
|
||
{draft && (
|
||
<>
|
||
{/* Tightened header: top row is identity (op + bk), bottom row is
|
||
provenance (who/when). Was a single flex-wrap line with four
|
||
spans — visually busy, hard to scan. */}
|
||
<div className="space-y-1 text-cell">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
|
||
{dictName ? (
|
||
<Link
|
||
to="/dictionaries/$name"
|
||
params={{ name: dictName }}
|
||
className="text-mono text-accent hover:underline"
|
||
>
|
||
{dictName}
|
||
</Link>
|
||
) : (
|
||
<span className="text-mono text-mute" title={draft.dictionaryId}>
|
||
{draft.dictionaryId.slice(0, 8)}…
|
||
</span>
|
||
)}
|
||
<span className="text-mute">/</span>
|
||
<span className="font-mono">{draft.businessKey}</span>
|
||
<DrawerStatusBadge status={draft.status} />
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-mute">
|
||
<span className="inline-flex items-center gap-1">
|
||
{t('reviews.drawer.maker')}: <UserCell uuid={draft.makerId} />
|
||
</span>
|
||
<span>
|
||
{t('reviews.drawer.submitted')}:{' '}
|
||
{new Date(draft.submittedAt).toLocaleString()}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Read-only decision banner — для всех terminal статусов. Показывает
|
||
reviewer'а + decision time + reviewer comment чтобы maker'у не
|
||
пришлось гадать "что произошло". */}
|
||
{!isPending && (
|
||
<div className="px-3 py-2 rounded-sm border border-line bg-line/30 text-cell space-y-1">
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-mute">
|
||
{draft.reviewerId && (
|
||
<span className="inline-flex items-center gap-1">
|
||
{t('reviews.drawer.reviewer', { defaultValue: 'Ревьюер' })}:{' '}
|
||
<UserCell uuid={draft.reviewerId} />
|
||
</span>
|
||
)}
|
||
{draft.reviewedAt && (
|
||
<span>
|
||
{t('reviews.drawer.reviewedAt', { defaultValue: 'Решение' })}:{' '}
|
||
{new Date(draft.reviewedAt).toLocaleString()}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{draft.reviewComment && (
|
||
<div>
|
||
<span className="text-mute uppercase tracking-[0.10em] font-display">
|
||
{t('reviews.drawer.reviewComment', { defaultValue: 'Комментарий' })}:
|
||
</span>{' '}
|
||
{draft.reviewComment}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{draft.makerComment && (
|
||
<div className="px-3 py-2 rounded-sm border border-line bg-line/30 text-cell">
|
||
<span className="text-mute uppercase tracking-[0.10em] font-display">
|
||
{t('reviews.drawer.makerComment')}:
|
||
</span>{' '}
|
||
{draft.makerComment}
|
||
</div>
|
||
)}
|
||
|
||
<DraftDiffSummary
|
||
live={liveQ.data?.data as Record<string, unknown> | null | undefined}
|
||
proposed={draft.data as Record<string, unknown> | null | undefined}
|
||
operation={draft.operation}
|
||
/>
|
||
|
||
{(() => {
|
||
// Считаем diff один раз для обеих колонок: changed/added/removed
|
||
// keys (top-level) подсветим через JsonView в обоих side-by-side
|
||
// блоках — reviewer видит точку изменения визуально, не сравнивая
|
||
// глазами длинные JSON-объекты.
|
||
const liveData =
|
||
(liveQ.data?.data as Record<string, unknown> | null | undefined) ?? null
|
||
const proposedData =
|
||
(draft.data as Record<string, unknown> | null | undefined) ?? null
|
||
const diff =
|
||
draft.operation === 'CLOSE'
|
||
? null
|
||
: shallowDiffSummary(liveData, proposedData)
|
||
const liveDiff = diff
|
||
? { removed: diff.removed, changed: diff.changed }
|
||
: undefined
|
||
const proposedDiff = diff
|
||
? { added: diff.added, changed: diff.changed }
|
||
: undefined
|
||
return (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div>
|
||
<p className="text-cap text-mute mb-1">
|
||
{t('reviews.drawer.live')}
|
||
</p>
|
||
{liveQ.isLoading && <LoadingBlock size="sm" label={t('loading')} />}
|
||
{liveQ.error && (
|
||
<p className="text-cell text-danger italic">
|
||
{t('reviews.drawer.liveError', {
|
||
defaultValue: 'Не удалось загрузить текущую запись',
|
||
})}
|
||
</p>
|
||
)}
|
||
{!liveQ.isLoading && !liveQ.error && (liveQ.data === null || isCreateDraft) && (
|
||
<p className="text-cell text-mute italic">
|
||
{t('reviews.drawer.noLive')}
|
||
</p>
|
||
)}
|
||
{liveQ.data && (
|
||
<JsonView value={liveQ.data.data} diff={liveDiff} />
|
||
)}
|
||
</div>
|
||
<div>
|
||
<p className="text-cap text-mute mb-1">
|
||
{t('reviews.drawer.proposed')}
|
||
</p>
|
||
{draft.operation === 'CLOSE' ? (
|
||
<p className="text-cell text-aurora italic">
|
||
{t('reviews.drawer.closeNote')}
|
||
</p>
|
||
) : (
|
||
<JsonView value={draft.data} diff={proposedDiff} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{(approveMut.error || rejectMut.error || withdrawMut.error) && (() => {
|
||
const err = approveMut.error || rejectMut.error || withdrawMut.error
|
||
const info = extractReviewError(err)
|
||
return (
|
||
<Alert variant="error" title={info.title}>
|
||
{info.body}
|
||
</Alert>
|
||
)
|
||
})()}
|
||
|
||
{/* Action footer split на три режима:
|
||
- terminal (не pending) — только Close, нет input/actions
|
||
- own pending — Withdraw, без comment input (maker не reviewer'ит)
|
||
- peer pending — approve/reject (existing reviewer flow) */}
|
||
{!isPending && (
|
||
<div className="border-t border-line pt-3 flex items-center justify-end">
|
||
<Button variant="secondary" onClick={onClose}>
|
||
{t('reviews.action.close', { defaultValue: 'Закрыть' })}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{isMineAndPending && (
|
||
<div className="border-t border-line pt-3 space-y-2">
|
||
<p className="text-cell text-mute">
|
||
{t('reviews.drawer.ownPendingHint', {
|
||
defaultValue: 'Это ваш черновик на ревью. Approve/reject запрещены — можно отозвать.',
|
||
})}
|
||
</p>
|
||
<div className="flex items-center justify-end gap-2 pt-2">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={onClose}
|
||
disabled={withdrawMut.isPending}
|
||
>
|
||
{t('reviews.action.cancel')}
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
leftIcon={<ArrowCounterClockwiseIcon weight="bold" size={14} />}
|
||
onClick={handleWithdraw}
|
||
disabled={withdrawMut.isPending}
|
||
loading={withdrawMut.isPending}
|
||
>
|
||
{t('reviews.action.withdraw', { defaultValue: 'Отозвать' })}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isPending && !isMineAndPending && (
|
||
<div
|
||
className={
|
||
'border-t pt-3 space-y-2 transition-colors duration-300 -mx-4 px-4 ' +
|
||
(decisionFlash === 'approve'
|
||
? 'border-green bg-green-bg/30'
|
||
: decisionFlash === 'reject'
|
||
? 'border-pink bg-pink-bg/30'
|
||
: 'border-line')
|
||
}
|
||
>
|
||
<label
|
||
htmlFor="review-comment"
|
||
className="text-cap text-ink-2"
|
||
>
|
||
{t('reviews.drawer.comment')}
|
||
</label>
|
||
<TextInput
|
||
id="review-comment"
|
||
value={comment}
|
||
onChange={(e) => {
|
||
setComment(e.target.value)
|
||
// Stale validation error gone как только user печатает.
|
||
if (rejectError && e.target.value.trim()) setRejectError(null)
|
||
}}
|
||
placeholder={t('reviews.drawer.commentPlaceholder')}
|
||
aria-invalid={Boolean(rejectError)}
|
||
aria-describedby={rejectError ? 'review-comment-error' : undefined}
|
||
/>
|
||
{rejectError && (
|
||
<p
|
||
id="review-comment-error"
|
||
role="alert"
|
||
className="text-cell text-pink"
|
||
>
|
||
{rejectError}
|
||
</p>
|
||
)}
|
||
<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={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>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Schema review pool queue — global view всех \`review_pending\` schema
|
||
* draft'ов через {@code GET /admin/schema-reviews/pending}. Reviewer
|
||
* жмёт «Открыть» → переходит на dict detail page, banner+drawer
|
||
* автоматически подхватятся через {@code useDictActiveSchemaDraft}.
|
||
*
|
||
* <p>Скрывается если 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 (
|
||
<Panel>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div>
|
||
<h2 className="text-title-md font-semibold">
|
||
{t('reviews.schema.title', { defaultValue: 'Изменения схем' })}
|
||
</h2>
|
||
<p className="text-cell text-mute mt-0.5">
|
||
{t('reviews.schema.queueTotal', {
|
||
count: queue.data?.totalElements ?? items.length,
|
||
defaultValue: `${queue.data?.totalElements ?? items.length} draft(s) ждут ревью`,
|
||
})}
|
||
</p>
|
||
</div>
|
||
<Badge variant="info">
|
||
{t('reviews.schema.label', { defaultValue: 'Schema workflow' })}
|
||
</Badge>
|
||
</div>
|
||
<Table>
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableHeaderCell>{t('reviews.schema.dictionary', { defaultValue: 'Словарь' })}</TableHeaderCell>
|
||
<TableHeaderCell>{t('reviews.schema.fromVersion', { defaultValue: 'От версии' })}</TableHeaderCell>
|
||
<TableHeaderCell>{t('reviews.schema.maker', { defaultValue: 'Автор' })}</TableHeaderCell>
|
||
<TableHeaderCell>{t('reviews.schema.submittedAt', { defaultValue: 'Отправлен' })}</TableHeaderCell>
|
||
<TableHeaderCell align="right">{t('reviews.schema.action', { defaultValue: 'Действие' })}</TableHeaderCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{items.map((d) => (
|
||
<TableRow key={d.draftId}>
|
||
<TableCell>
|
||
<Link
|
||
to="/dictionaries/$name"
|
||
params={{ name: d.dictionaryName }}
|
||
className="text-accent hover:underline font-mono"
|
||
>
|
||
{d.dictionaryName}
|
||
</Link>
|
||
</TableCell>
|
||
<TableCell className="font-mono tabular-nums">v{d.branchedFromVersion}</TableCell>
|
||
<TableCell><UserCell uuid={d.makerId} /></TableCell>
|
||
<TableCell className="tabular-nums">
|
||
{d.submittedAt ? new Date(d.submittedAt).toLocaleString() : '—'}
|
||
</TableCell>
|
||
<TableCell align="right">
|
||
<Link
|
||
to="/dictionaries/$name"
|
||
params={{ name: d.dictionaryName }}
|
||
className="text-accent hover:underline"
|
||
>
|
||
{t('reviews.schema.open', { defaultValue: 'Открыть' })} →
|
||
</Link>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</Panel>
|
||
)
|
||
}
|