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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user