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
+49
View File
@@ -37,6 +37,9 @@ export type DictionaryDefinition = {
defaultLocale: string
/** CEO plan E2: per-dict Redis projection materialization opt-in. */
redisProjectionEnabled: boolean
/** Approval Workflow v2: per-dict opt-in. */
approvalRequired: boolean
approvalMinRole?: string | null
recordCount?: number
createdAt: string
updatedAt: string
@@ -107,6 +110,10 @@ export type CreateDictionaryRequest = {
defaultLocale?: string
/** CEO plan E2: per-dict Redis projection opt-in. Default false. */
redisProjectionEnabled?: boolean
/** Approval Workflow v2: per-dict opt-in. Default false. */
approvalRequired?: boolean
/** Optional Keycloak role для reviewer'ов на этот dict. */
approvalMinRole?: string
}
export type CreateRecordRequest = {
@@ -246,6 +253,48 @@ export type WebhookDeliveryPage = {
size: number
}
// === Approval Workflow v2 ===
export type DraftStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'WITHDRAWN'
export type DraftOperation = 'CREATE' | 'UPDATE' | 'CLOSE'
export type DraftResponse = {
id: string
dictionaryId: string
businessKey: string
parentRecordId?: string | null
operation: DraftOperation
data?: Record<string, unknown> | null
geometryWkt?: string | null
proposedValidFrom?: string | null
proposedValidTo?: string | null
status: DraftStatus
makerId: string
makerComment?: string | null
submittedAt: string
reviewerId?: string | null
reviewedAt?: string | null
reviewComment?: string | null
}
export type SubmitDraftRequest = {
businessKey: string
operation: DraftOperation
data?: Record<string, unknown> | null
geometryWkt?: string | null
validFrom?: string | null
validTo?: string | null
comment?: string | null
}
export type ReviewQueuePage = {
items: DraftResponse[]
page: number
size: number
totalElements: number
totalPages: number
}
// === Smart search (CEO plan v1) ===
export type SearchItem = {
+94
View File
@@ -8,7 +8,9 @@ import {
type CreateRecordRequest,
type CreateWebhookSubscriptionRequest,
type DictionaryDetail,
type DraftResponse,
type RecordResponse,
type SubmitDraftRequest,
type WebhookDelivery,
type WebhookSubscription,
type WebhookTestPingResult,
@@ -293,6 +295,98 @@ export const useTestWebhook = () => {
* Use case: оператор fixed receiver URL/cert, хочет replay'нуть failed
* delivery без ожидания TTL bypass'а.
*/
// === Approval Workflow v2 mutations ===
/**
* Maker submits a proposal. Backend creates draft с status=PENDING.
* 409 draft_already_pending если уже есть pending draft на этот business_key.
*/
export const useSubmitDraft = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (req: SubmitDraftRequest): Promise<DraftResponse> => {
const { data } = await apiClient.post<DraftResponse>(
`/dictionaries/${encodeURIComponent(dictionaryName)}/records/drafts`,
req,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['review-queue'] })
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
},
})
}
/**
* Reviewer approves pending draft. SERIALIZABLE tx → COPY draft → live +
* outbox event. 409 draft_not_pending / self_approve_forbidden если что-то
* не так.
*/
export const useApproveDraft = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: {
id: string
comment?: string
}): Promise<DraftResponse> => {
const { data } = await apiClient.post<DraftResponse>(
`/drafts/${encodeURIComponent(params.id)}/approve`,
null,
{ params: params.comment ? { comment: params.comment } : undefined },
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['review-queue'] })
qc.invalidateQueries({ queryKey: ['records'] })
qc.invalidateQueries({ queryKey: ['draft'] })
},
})
}
/**
* Reviewer rejects pending draft. Reason required (compliance, D4=A
* soft-archive). 400 reject_reason_required если reason пустой.
*/
export const useRejectDraft = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: {
id: string
comment: string
}): Promise<DraftResponse> => {
const { data } = await apiClient.post<DraftResponse>(
`/drafts/${encodeURIComponent(params.id)}/reject`,
null,
{ params: { comment: params.comment } },
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['review-queue'] })
qc.invalidateQueries({ queryKey: ['draft'] })
},
})
}
/** Maker отзывает свой pending draft. 403 not_draft_maker если не maker. */
export const useWithdrawDraft = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (id: string): Promise<DraftResponse> => {
const { data } = await apiClient.delete<DraftResponse>(
`/drafts/${encodeURIComponent(id)}`,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['review-queue'] })
qc.invalidateQueries({ queryKey: ['draft'] })
},
})
}
export const useRetryWebhookDelivery = () => {
const qc = useQueryClient()
return useMutation({
+62
View File
@@ -7,10 +7,12 @@ import {
type DictionaryDefinition,
type DictionaryDetail,
type DlqPage,
type DraftResponse,
type FlattenedRecord,
type OutboxStats,
type RecordDependentsPage,
type RecordResponse,
type ReviewQueuePage,
type SchemaDependent,
type SearchResponse,
type WebhookDeliveryPage,
@@ -388,6 +390,66 @@ export const useSearch = (q: string | undefined, size = 100, perDict = 10) =>
enabled: Boolean(q && q.length >= 3),
})
// === Approval Workflow v2 ===
/** Global reviewer queue — pool model (D3=A). All pending drafts FIFO. */
export const reviewQueueQuery = (page = 0, size = 50) =>
queryOptions({
queryKey: ['review-queue', page, size] as const,
queryFn: async (): Promise<ReviewQueuePage> => {
const { data } = await apiClient.get<ReviewQueuePage>('/admin/reviews/pending', {
params: { page, size },
})
return data
},
refetchInterval: 30_000,
})
export const useReviewQueue = (page = 0, size = 50) =>
useQuery(reviewQueueQuery(page, size))
/** Single draft by id — for diff viewer drawer. */
export const draftQuery = (id: string) =>
queryOptions({
queryKey: ['draft', id] as const,
queryFn: async (): Promise<DraftResponse> => {
const { data } = await apiClient.get<DraftResponse>(
`/drafts/${encodeURIComponent(id)}`,
)
return data
},
})
export const useDraft = (id: string | undefined) =>
useQuery({
...draftQuery(id ?? ''),
enabled: Boolean(id),
})
/** Live record by businessKey — for diff comparison "current vs proposed". */
export const liveRecordQuery = (dict: string, businessKey: string) =>
queryOptions({
queryKey: ['live-record', dict, businessKey] as const,
queryFn: async (): Promise<RecordResponse | null> => {
try {
const { data } = await apiClient.get<RecordResponse>(
`/dictionaries/${encodeURIComponent(dict)}/records/${encodeURIComponent(businessKey)}`,
)
return data
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status
if (status === 404) return null
throw e
}
},
})
export const useLiveRecord = (dict: string | undefined, businessKey: string | undefined) =>
useQuery({
...liveRecordQuery(dict ?? '', businessKey ?? ''),
enabled: Boolean(dict && businessKey),
})
export const useDictionaries = () => useQuery(dictionariesQuery)
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
export const useRecords = (
@@ -75,6 +75,12 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
const [redisProjection, setRedisProjection] = useState(
initial?.redisProjectionEnabled ?? false,
)
const [approvalRequired, setApprovalRequired] = useState(
(initial as { approvalRequired?: boolean } | null)?.approvalRequired ?? false,
)
const [approvalMinRole, setApprovalMinRole] = useState(
(initial as { approvalMinRole?: string } | null)?.approvalMinRole ?? '',
)
const [properties, setProperties] = useState<PropertyDef[]>(parsed.properties)
const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '')
const [nameError, setNameError] = useState<string | null>(null)
@@ -161,6 +167,8 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
supportedLocales: supportedLocales.length > 0 ? supportedLocales : undefined,
defaultLocale: defaultLocale || undefined,
redisProjectionEnabled: redisProjection,
approvalRequired,
approvalMinRole: approvalMinRole.trim() || undefined,
}
if (isEdit) {
@@ -308,6 +316,46 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
</span>
</span>
</label>
{/* Approval Workflow v2: per-dict opt-in. Default false. Включается
для критичных dicts (ground_station, spacecraft и т.д.) где
один admin не должен иметь power публиковать без review. */}
<label className="md:col-span-2 flex items-start gap-3 px-3 py-2 rounded-sm border border-orbit/40 bg-orbit/8 cursor-pointer">
<input
type="checkbox"
checked={approvalRequired}
onChange={(e) => setApprovalRequired(e.target.checked)}
className="mt-0.5 size-4 accent-orbit"
/>
<span className="flex-1">
<span className="block text-sm font-primary text-carbon">
{t('schema.approvalRequired.label')}
</span>
<span className="block text-2xs text-carbon/60 mt-0.5">
{t('schema.approvalRequired.hint')}
</span>
</span>
</label>
{approvalRequired && (
<div className="md:col-span-2">
<label
htmlFor="approval-min-role"
className="text-2xs uppercase tracking-label text-carbon/70"
>
{t('schema.approvalMinRole.label')}
</label>
<TextInput
id="approval-min-role"
value={approvalMinRole}
onChange={(e) => setApprovalMinRole(e.target.value)}
placeholder="ordinis:internal"
/>
<p className="text-2xs text-carbon/60 mt-0.5">
{t('schema.approvalMinRole.hint')}
</p>
</div>
)}
</div>
</div>
+62
View File
@@ -18,6 +18,38 @@ i18n
'nav.outbox': 'Outbox',
'nav.webhooks': 'Webhooks',
'nav.search': 'Поиск',
'nav.reviews': 'Согласования',
'reviews.title': 'Очередь согласований',
'reviews.description': 'Pending drafts от makers, ждут approve/reject. Pool model: первый approver обрабатывает.',
'reviews.empty': 'Нет pending согласований. Все обработаны 👍',
'reviews.queueTotal_one': '{{count}} pending draft',
'reviews.queueTotal_few': '{{count}} pending draft',
'reviews.queueTotal_many': '{{count}} pending drafts',
'reviews.queueTotal_other': '{{count}} pending drafts',
'reviews.col.dict': 'Справочник',
'reviews.col.bk': 'Business key',
'reviews.col.op': 'Операция',
'reviews.col.maker': 'Автор',
'reviews.col.submitted': 'Отправлен',
'reviews.col.actions': 'Действия',
'reviews.action.review': 'Просмотр',
'reviews.action.approve': 'Approve',
'reviews.action.reject': 'Reject',
'reviews.action.cancel': 'Отмена',
'reviews.drawer.title': 'Согласование draft',
'reviews.drawer.maker': 'автор',
'reviews.drawer.submitted': 'отправлен',
'reviews.drawer.makerComment': 'Комментарий автора',
'reviews.drawer.live': 'Текущая (live)',
'reviews.drawer.proposed': 'Предложено',
'reviews.drawer.noLive': 'Нет live-записи (CREATE).',
'reviews.drawer.closeNote': 'CLOSE: запись будет закрыта (valid_to=now).',
'reviews.drawer.comment': 'Комментарий ревьюера',
'reviews.drawer.commentPlaceholder': 'Для approve опционально, для reject обязательно',
'schema.approvalRequired.label': 'Требовать approval (maker-checker)',
'schema.approvalRequired.hint': 'Approval Workflow v2. Все CRUD операции пойдут через draft → review → approve. Защита от operator error на критичных справочниках. Существующие маршруты (POST/PUT/DELETE) вернут 409 draft_required с pointer на /drafts API.',
'schema.approvalMinRole.label': 'Минимальная роль ревьюера (опционально)',
'schema.approvalMinRole.hint': 'Например ordinis:internal или ordinis:restricted. Пусто = любой пользователь с ролью ordinis:approver. (Phase 2 — enforcement TBD).',
'search.title': 'Smart search',
'search.description': 'Поиск по содержимому всех справочников. Активные записи, текущий момент. Min 3 символа (trigram index).',
'search.placeholder': 'Введите 3+ символа: код, название, идентификатор…',
@@ -390,6 +422,36 @@ i18n
'nav.outbox': 'Outbox',
'nav.webhooks': 'Webhooks',
'nav.search': 'Search',
'nav.reviews': 'Reviews',
'reviews.title': 'Review queue',
'reviews.description': 'Pending drafts from makers awaiting approve/reject. Pool model: first approver handles.',
'reviews.empty': 'No pending reviews. All clear 👍',
'reviews.queueTotal_one': '{{count}} pending draft',
'reviews.queueTotal_other': '{{count}} pending drafts',
'reviews.col.dict': 'Dictionary',
'reviews.col.bk': 'Business key',
'reviews.col.op': 'Operation',
'reviews.col.maker': 'Maker',
'reviews.col.submitted': 'Submitted',
'reviews.col.actions': 'Actions',
'reviews.action.review': 'Review',
'reviews.action.approve': 'Approve',
'reviews.action.reject': 'Reject',
'reviews.action.cancel': 'Cancel',
'reviews.drawer.title': 'Review draft',
'reviews.drawer.maker': 'maker',
'reviews.drawer.submitted': 'submitted',
'reviews.drawer.makerComment': 'Maker comment',
'reviews.drawer.live': 'Current (live)',
'reviews.drawer.proposed': 'Proposed',
'reviews.drawer.noLive': 'No live record (CREATE).',
'reviews.drawer.closeNote': 'CLOSE: record will be closed (valid_to=now).',
'reviews.drawer.comment': 'Reviewer comment',
'reviews.drawer.commentPlaceholder': 'Optional for approve, required for reject',
'schema.approvalRequired.label': 'Require approval (maker-checker)',
'schema.approvalRequired.hint': 'Approval Workflow v2. All CRUD goes through draft → review → approve. Protects critical dictionaries from operator error. Existing endpoints (POST/PUT/DELETE) return 409 draft_required pointing to /drafts API.',
'schema.approvalMinRole.label': 'Minimum reviewer role (optional)',
'schema.approvalMinRole.hint': 'e.g. ordinis:internal or ordinis:restricted. Empty = any user with ordinis:approver. (Phase 2 — enforcement TBD).',
'search.title': 'Smart search',
'search.description': 'Search across all dictionary content. Active records, current moment. Min 3 chars (trigram index).',
'search.placeholder': 'Type 3+ chars: code, name, identifier…',
+21
View File
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as WebhooksRouteImport } from './routes/webhooks'
import { Route as SearchRouteImport } from './routes/search'
import { Route as ReviewsRouteImport } from './routes/reviews'
import { Route as OutboxRouteImport } from './routes/outbox'
import { Route as DictionariesRouteImport } from './routes/dictionaries'
import { Route as AuditRouteImport } from './routes/audit'
@@ -30,6 +31,11 @@ const SearchRoute = SearchRouteImport.update({
path: '/search',
getParentRoute: () => rootRouteImport,
} as any)
const ReviewsRoute = ReviewsRouteImport.update({
id: '/reviews',
path: '/reviews',
getParentRoute: () => rootRouteImport,
} as any)
const OutboxRoute = OutboxRouteImport.update({
id: '/outbox',
path: '/outbox',
@@ -76,6 +82,7 @@ export interface FileRoutesByFullPath {
'/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren
'/outbox': typeof OutboxRoute
'/reviews': typeof ReviewsRoute
'/search': typeof SearchRoute
'/webhooks': typeof WebhooksRouteWithChildren
'/dictionaries/$name': typeof DictionariesNameRoute
@@ -87,6 +94,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/audit': typeof AuditRoute
'/outbox': typeof OutboxRoute
'/reviews': typeof ReviewsRoute
'/search': typeof SearchRoute
'/dictionaries/$name': typeof DictionariesNameRoute
'/webhooks/$id': typeof WebhooksIdRoute
@@ -99,6 +107,7 @@ export interface FileRoutesById {
'/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren
'/outbox': typeof OutboxRoute
'/reviews': typeof ReviewsRoute
'/search': typeof SearchRoute
'/webhooks': typeof WebhooksRouteWithChildren
'/dictionaries/$name': typeof DictionariesNameRoute
@@ -113,6 +122,7 @@ export interface FileRouteTypes {
| '/audit'
| '/dictionaries'
| '/outbox'
| '/reviews'
| '/search'
| '/webhooks'
| '/dictionaries/$name'
@@ -124,6 +134,7 @@ export interface FileRouteTypes {
| '/'
| '/audit'
| '/outbox'
| '/reviews'
| '/search'
| '/dictionaries/$name'
| '/webhooks/$id'
@@ -135,6 +146,7 @@ export interface FileRouteTypes {
| '/audit'
| '/dictionaries'
| '/outbox'
| '/reviews'
| '/search'
| '/webhooks'
| '/dictionaries/$name'
@@ -148,6 +160,7 @@ export interface RootRouteChildren {
AuditRoute: typeof AuditRoute
DictionariesRoute: typeof DictionariesRouteWithChildren
OutboxRoute: typeof OutboxRoute
ReviewsRoute: typeof ReviewsRoute
SearchRoute: typeof SearchRoute
WebhooksRoute: typeof WebhooksRouteWithChildren
}
@@ -168,6 +181,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SearchRouteImport
parentRoute: typeof rootRouteImport
}
'/reviews': {
id: '/reviews'
path: '/reviews'
fullPath: '/reviews'
preLoaderRoute: typeof ReviewsRouteImport
parentRoute: typeof rootRouteImport
}
'/outbox': {
id: '/outbox'
path: '/outbox'
@@ -260,6 +280,7 @@ const rootRouteChildren: RootRouteChildren = {
AuditRoute: AuditRoute,
DictionariesRoute: DictionariesRouteWithChildren,
OutboxRoute: OutboxRoute,
ReviewsRoute: ReviewsRoute,
SearchRoute: SearchRoute,
WebhooksRoute: WebhooksRouteWithChildren,
}
+7
View File
@@ -58,6 +58,13 @@ function RootLayout() {
>
{t('nav.search')}
</Link>
<Link
to="/reviews"
className="text-carbon hover:text-ultramarain"
activeProps={{ className: 'text-ultramarain font-medium' }}
>
{t('nav.reviews')}
</Link>
</nav>
<div className="ml-auto flex items-center gap-3">
<LanguageSwitch
+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>
)
}