feat(approval): Approval Workflow v2 Phase 2 — admin UI reviewer queue + toggle

Phase 2 admin UI на скелете Phase 0/1 (e438c4c, 2020af9). Делает workflow
end-to-end usable: reviewer'ы могут approve/reject через UI; admins
включают approval_required per-dict через checkbox.

API surface (admin-ui):
- types: DraftStatus, DraftOperation, DraftResponse, SubmitDraftRequest,
  ReviewQueuePage. + DictionaryDefinition: approvalRequired, approvalMinRole.
- queries (react-query):
  * useReviewQueue (page, size) — global queue, refetch 30s.
  * useDraft(id) — single draft for diff drawer.
  * useLiveRecord(dict, bk) — current live record for side-by-side comparison.
- mutations:
  * useSubmitDraft(dict) — POST /dictionaries/{n}/records/drafts
  * useApproveDraft() — POST /drafts/{id}/approve
  * useRejectDraft() — POST /drafts/{id}/reject (reason required)
  * useWithdrawDraft() — DELETE /drafts/{id}

New /reviews route:
- Table queue: dict, business_key, operation badge, maker, submitted_at.
- Click "Просмотр" → side-by-side drawer:
  * left: current live (если есть; для CREATE — placeholder).
  * right: proposed (или CLOSE notice).
  * maker comment chip если есть.
  * Reviewer comment input + Approve / Reject buttons.
  * Reject disabled пока comment пустой (mirror's backend 400).
- Auto-invalidates на success — queue refresh, list updates.
- Nav link "Согласования" в header.

DictionaryEditorDialog Metadata tab:
- New checkbox "Требовать approval (maker-checker)" — orbit/8 styled
  (отделимо от Redis projection card visually).
- При checked → shown approval_min_role TextInput (e.g. "ordinis:internal").
- payload sends approvalRequired + approvalMinRole.

i18n RU/EN:
- nav.reviews, reviews.{title,description,empty,queueTotal_*,col.*,
  action.*, drawer.*}
- schema.approvalRequired.{label,hint}, schema.approvalMinRole.{label,hint}
- Plurals для RU queueTotal (one/few/many/other).

Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
- routeTree.gen.ts auto-regenerated с /reviews.

Phase 3 (next session):
- e2e tests: full flow maker submit → checker approve → record live + outbox event.
- e2e: reject keeps draft, doesn't publish.
- e2e: self-approve blocked (no_self_approve constraint).
- e2e: two pending drafts на one business_key blocked (one_pending_per_key).
- Per-dict opt-in soak: 1-2 dicts (например ground_station) с
  approval_required=true в staging.
This commit is contained in:
Zimin A.N.
2026-05-08 13:28:56 +03:00
parent 2020af99b7
commit 84a4de4ceb
8 changed files with 640 additions and 0 deletions
+297
View File
@@ -0,0 +1,297 @@
import { useState } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
Badge,
Button,
Drawer,
EmptyState,
LoadingBlock,
PageHeader,
Panel,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
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 } 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.
*/
function ReviewsPage() {
const { t } = useTranslation()
const queue = useReviewQueue(0, 50)
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
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>
)}
{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">
<p className="text-2xs text-carbon/60">
{t('reviews.queueTotal', { count: queue.data.totalElements })}
</p>
</div>
<Table>
<TableHead>
<TableRow>
<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>
{queue.data.items.map((d) => (
<TableRow key={d.id}>
<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)}
/>
</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>
)
}