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