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
+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 = (