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 = {