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 defaultLocale: string
/** CEO plan E2: per-dict Redis projection materialization opt-in. */ /** CEO plan E2: per-dict Redis projection materialization opt-in. */
redisProjectionEnabled: boolean redisProjectionEnabled: boolean
/** Approval Workflow v2: per-dict opt-in. */
approvalRequired: boolean
approvalMinRole?: string | null
recordCount?: number recordCount?: number
createdAt: string createdAt: string
updatedAt: string updatedAt: string
@@ -107,6 +110,10 @@ export type CreateDictionaryRequest = {
defaultLocale?: string defaultLocale?: string
/** CEO plan E2: per-dict Redis projection opt-in. Default false. */ /** CEO plan E2: per-dict Redis projection opt-in. Default false. */
redisProjectionEnabled?: boolean redisProjectionEnabled?: boolean
/** Approval Workflow v2: per-dict opt-in. Default false. */
approvalRequired?: boolean
/** Optional Keycloak role для reviewer'ов на этот dict. */
approvalMinRole?: string
} }
export type CreateRecordRequest = { export type CreateRecordRequest = {
@@ -246,6 +253,48 @@ export type WebhookDeliveryPage = {
size: number 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) === // === Smart search (CEO plan v1) ===
export type SearchItem = { export type SearchItem = {
+94
View File
@@ -8,7 +8,9 @@ import {
type CreateRecordRequest, type CreateRecordRequest,
type CreateWebhookSubscriptionRequest, type CreateWebhookSubscriptionRequest,
type DictionaryDetail, type DictionaryDetail,
type DraftResponse,
type RecordResponse, type RecordResponse,
type SubmitDraftRequest,
type WebhookDelivery, type WebhookDelivery,
type WebhookSubscription, type WebhookSubscription,
type WebhookTestPingResult, type WebhookTestPingResult,
@@ -293,6 +295,98 @@ export const useTestWebhook = () => {
* Use case: оператор fixed receiver URL/cert, хочет replay'нуть failed * Use case: оператор fixed receiver URL/cert, хочет replay'нуть failed
* delivery без ожидания TTL bypass'а. * 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 = () => { export const useRetryWebhookDelivery = () => {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
+62
View File
@@ -7,10 +7,12 @@ import {
type DictionaryDefinition, type DictionaryDefinition,
type DictionaryDetail, type DictionaryDetail,
type DlqPage, type DlqPage,
type DraftResponse,
type FlattenedRecord, type FlattenedRecord,
type OutboxStats, type OutboxStats,
type RecordDependentsPage, type RecordDependentsPage,
type RecordResponse, type RecordResponse,
type ReviewQueuePage,
type SchemaDependent, type SchemaDependent,
type SearchResponse, type SearchResponse,
type WebhookDeliveryPage, type WebhookDeliveryPage,
@@ -388,6 +390,66 @@ export const useSearch = (q: string | undefined, size = 100, perDict = 10) =>
enabled: Boolean(q && q.length >= 3), 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 useDictionaries = () => useQuery(dictionariesQuery)
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name)) export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
export const useRecords = ( export const useRecords = (
@@ -75,6 +75,12 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
const [redisProjection, setRedisProjection] = useState( const [redisProjection, setRedisProjection] = useState(
initial?.redisProjectionEnabled ?? false, 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 [properties, setProperties] = useState<PropertyDef[]>(parsed.properties)
const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '') const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '')
const [nameError, setNameError] = useState<string | null>(null) 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, supportedLocales: supportedLocales.length > 0 ? supportedLocales : undefined,
defaultLocale: defaultLocale || undefined, defaultLocale: defaultLocale || undefined,
redisProjectionEnabled: redisProjection, redisProjectionEnabled: redisProjection,
approvalRequired,
approvalMinRole: approvalMinRole.trim() || undefined,
} }
if (isEdit) { if (isEdit) {
@@ -308,6 +316,46 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
</span> </span>
</span> </span>
</label> </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>
</div> </div>
+62
View File
@@ -18,6 +18,38 @@ i18n
'nav.outbox': 'Outbox', 'nav.outbox': 'Outbox',
'nav.webhooks': 'Webhooks', 'nav.webhooks': 'Webhooks',
'nav.search': 'Поиск', '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.title': 'Smart search',
'search.description': 'Поиск по содержимому всех справочников. Активные записи, текущий момент. Min 3 символа (trigram index).', 'search.description': 'Поиск по содержимому всех справочников. Активные записи, текущий момент. Min 3 символа (trigram index).',
'search.placeholder': 'Введите 3+ символа: код, название, идентификатор…', 'search.placeholder': 'Введите 3+ символа: код, название, идентификатор…',
@@ -390,6 +422,36 @@ i18n
'nav.outbox': 'Outbox', 'nav.outbox': 'Outbox',
'nav.webhooks': 'Webhooks', 'nav.webhooks': 'Webhooks',
'nav.search': 'Search', '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.title': 'Smart search',
'search.description': 'Search across all dictionary content. Active records, current moment. Min 3 chars (trigram index).', 'search.description': 'Search across all dictionary content. Active records, current moment. Min 3 chars (trigram index).',
'search.placeholder': 'Type 3+ chars: code, name, identifier…', '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 rootRouteImport } from './routes/__root'
import { Route as WebhooksRouteImport } from './routes/webhooks' import { Route as WebhooksRouteImport } from './routes/webhooks'
import { Route as SearchRouteImport } from './routes/search' import { Route as SearchRouteImport } from './routes/search'
import { Route as ReviewsRouteImport } from './routes/reviews'
import { Route as OutboxRouteImport } from './routes/outbox' import { Route as OutboxRouteImport } from './routes/outbox'
import { Route as DictionariesRouteImport } from './routes/dictionaries' import { Route as DictionariesRouteImport } from './routes/dictionaries'
import { Route as AuditRouteImport } from './routes/audit' import { Route as AuditRouteImport } from './routes/audit'
@@ -30,6 +31,11 @@ const SearchRoute = SearchRouteImport.update({
path: '/search', path: '/search',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ReviewsRoute = ReviewsRouteImport.update({
id: '/reviews',
path: '/reviews',
getParentRoute: () => rootRouteImport,
} as any)
const OutboxRoute = OutboxRouteImport.update({ const OutboxRoute = OutboxRouteImport.update({
id: '/outbox', id: '/outbox',
path: '/outbox', path: '/outbox',
@@ -76,6 +82,7 @@ export interface FileRoutesByFullPath {
'/audit': typeof AuditRoute '/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren '/dictionaries': typeof DictionariesRouteWithChildren
'/outbox': typeof OutboxRoute '/outbox': typeof OutboxRoute
'/reviews': typeof ReviewsRoute
'/search': typeof SearchRoute '/search': typeof SearchRoute
'/webhooks': typeof WebhooksRouteWithChildren '/webhooks': typeof WebhooksRouteWithChildren
'/dictionaries/$name': typeof DictionariesNameRoute '/dictionaries/$name': typeof DictionariesNameRoute
@@ -87,6 +94,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/audit': typeof AuditRoute '/audit': typeof AuditRoute
'/outbox': typeof OutboxRoute '/outbox': typeof OutboxRoute
'/reviews': typeof ReviewsRoute
'/search': typeof SearchRoute '/search': typeof SearchRoute
'/dictionaries/$name': typeof DictionariesNameRoute '/dictionaries/$name': typeof DictionariesNameRoute
'/webhooks/$id': typeof WebhooksIdRoute '/webhooks/$id': typeof WebhooksIdRoute
@@ -99,6 +107,7 @@ export interface FileRoutesById {
'/audit': typeof AuditRoute '/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren '/dictionaries': typeof DictionariesRouteWithChildren
'/outbox': typeof OutboxRoute '/outbox': typeof OutboxRoute
'/reviews': typeof ReviewsRoute
'/search': typeof SearchRoute '/search': typeof SearchRoute
'/webhooks': typeof WebhooksRouteWithChildren '/webhooks': typeof WebhooksRouteWithChildren
'/dictionaries/$name': typeof DictionariesNameRoute '/dictionaries/$name': typeof DictionariesNameRoute
@@ -113,6 +122,7 @@ export interface FileRouteTypes {
| '/audit' | '/audit'
| '/dictionaries' | '/dictionaries'
| '/outbox' | '/outbox'
| '/reviews'
| '/search' | '/search'
| '/webhooks' | '/webhooks'
| '/dictionaries/$name' | '/dictionaries/$name'
@@ -124,6 +134,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/audit' | '/audit'
| '/outbox' | '/outbox'
| '/reviews'
| '/search' | '/search'
| '/dictionaries/$name' | '/dictionaries/$name'
| '/webhooks/$id' | '/webhooks/$id'
@@ -135,6 +146,7 @@ export interface FileRouteTypes {
| '/audit' | '/audit'
| '/dictionaries' | '/dictionaries'
| '/outbox' | '/outbox'
| '/reviews'
| '/search' | '/search'
| '/webhooks' | '/webhooks'
| '/dictionaries/$name' | '/dictionaries/$name'
@@ -148,6 +160,7 @@ export interface RootRouteChildren {
AuditRoute: typeof AuditRoute AuditRoute: typeof AuditRoute
DictionariesRoute: typeof DictionariesRouteWithChildren DictionariesRoute: typeof DictionariesRouteWithChildren
OutboxRoute: typeof OutboxRoute OutboxRoute: typeof OutboxRoute
ReviewsRoute: typeof ReviewsRoute
SearchRoute: typeof SearchRoute SearchRoute: typeof SearchRoute
WebhooksRoute: typeof WebhooksRouteWithChildren WebhooksRoute: typeof WebhooksRouteWithChildren
} }
@@ -168,6 +181,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SearchRouteImport preLoaderRoute: typeof SearchRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/reviews': {
id: '/reviews'
path: '/reviews'
fullPath: '/reviews'
preLoaderRoute: typeof ReviewsRouteImport
parentRoute: typeof rootRouteImport
}
'/outbox': { '/outbox': {
id: '/outbox' id: '/outbox'
path: '/outbox' path: '/outbox'
@@ -260,6 +280,7 @@ const rootRouteChildren: RootRouteChildren = {
AuditRoute: AuditRoute, AuditRoute: AuditRoute,
DictionariesRoute: DictionariesRouteWithChildren, DictionariesRoute: DictionariesRouteWithChildren,
OutboxRoute: OutboxRoute, OutboxRoute: OutboxRoute,
ReviewsRoute: ReviewsRoute,
SearchRoute: SearchRoute, SearchRoute: SearchRoute,
WebhooksRoute: WebhooksRouteWithChildren, WebhooksRoute: WebhooksRouteWithChildren,
} }
+7
View File
@@ -58,6 +58,13 @@ function RootLayout() {
> >
{t('nav.search')} {t('nav.search')}
</Link> </Link>
<Link
to="/reviews"
className="text-carbon hover:text-ultramarain"
activeProps={{ className: 'text-ultramarain font-medium' }}
>
{t('nav.reviews')}
</Link>
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<LanguageSwitch <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>
)
}