From 84a4de4ceb02a14b51fed2db0ee5fdd1b1872549 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Fri, 8 May 2026 13:28:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(approval):=20Approval=20Workflow=20v2=20Ph?= =?UTF-8?q?ase=202=20=E2=80=94=20admin=20UI=20reviewer=20queue=20+=20toggl?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ordinis-admin-ui/src/api/client.ts | 49 +++ ordinis-admin-ui/src/api/mutations.ts | 94 ++++++ ordinis-admin-ui/src/api/queries.ts | 62 ++++ .../schema/DictionaryEditorDialog.tsx | 48 +++ ordinis-admin-ui/src/i18n.ts | 62 ++++ ordinis-admin-ui/src/routeTree.gen.ts | 21 ++ ordinis-admin-ui/src/routes/__root.tsx | 7 + ordinis-admin-ui/src/routes/reviews.tsx | 297 ++++++++++++++++++ 8 files changed, 640 insertions(+) create mode 100644 ordinis-admin-ui/src/routes/reviews.tsx diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 7d9997a..73008ee 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -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 | 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 | 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 = { diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index 1a88fb4..31560a8 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -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 => { + const { data } = await apiClient.post( + `/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 => { + const { data } = await apiClient.post( + `/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 => { + const { data } = await apiClient.post( + `/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 => { + const { data } = await apiClient.delete( + `/drafts/${encodeURIComponent(id)}`, + ) + return data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['review-queue'] }) + qc.invalidateQueries({ queryKey: ['draft'] }) + }, + }) +} + export const useRetryWebhookDelivery = () => { const qc = useQueryClient() return useMutation({ diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index ebc533b..6b2182c 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -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 => { + const { data } = await apiClient.get('/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 => { + const { data } = await apiClient.get( + `/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 => { + try { + const { data } = await apiClient.get( + `/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 = ( diff --git a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx index 982a3b0..1451d90 100644 --- a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx +++ b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx @@ -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(parsed.properties) const [idSource, setIdSource] = useState(parsed.idSource ?? '') const [nameError, setNameError] = useState(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 + + {/* Approval Workflow v2: per-dict opt-in. Default false. Включается + для критичных dicts (ground_station, spacecraft и т.д.) где + один admin не должен иметь power публиковать без review. */} + + + {approvalRequired && ( +
+ + setApprovalMinRole(e.target.value)} + placeholder="ordinis:internal" + /> +

+ {t('schema.approvalMinRole.hint')} +

+
+ )} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 1fbb268..0914e45 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -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…', diff --git a/ordinis-admin-ui/src/routeTree.gen.ts b/ordinis-admin-ui/src/routeTree.gen.ts index 556f9c9..298a9ff 100644 --- a/ordinis-admin-ui/src/routeTree.gen.ts +++ b/ordinis-admin-ui/src/routeTree.gen.ts @@ -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, } diff --git a/ordinis-admin-ui/src/routes/__root.tsx b/ordinis-admin-ui/src/routes/__root.tsx index e03e009..f59948a 100644 --- a/ordinis-admin-ui/src/routes/__root.tsx +++ b/ordinis-admin-ui/src/routes/__root.tsx @@ -58,6 +58,13 @@ function RootLayout() { > {t('nav.search')} + + {t('nav.reviews')} +
{ + if (op === 'CREATE') return 'success' + if (op === 'CLOSE') return 'warning' + return 'info' +} + +/** + * Approval Workflow v2 — reviewer queue (D3=A pool model). + * + *

Все 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(undefined) + + return ( +

+ + + {queue.isLoading && } + + {queue.error && ( + + {String(queue.error)} + + )} + + {queue.data && queue.data.totalElements === 0 && ( + + )} + + {queue.data && queue.data.totalElements > 0 && ( + +
+

+ {t('reviews.queueTotal', { count: queue.data.totalElements })} +

+
+ + + + {t('reviews.col.dict')} + {t('reviews.col.bk')} + {t('reviews.col.op')} + {t('reviews.col.maker')} + {t('reviews.col.submitted')} + {t('reviews.col.actions')} + + + + {queue.data.items.map((d) => ( + + + + {d.dictionaryId.slice(0, 8)}… + + + {d.businessKey} + + {d.operation} + + {d.makerId} + + {new Date(d.submittedAt).toLocaleString()} + + + + + + ))} + +
+
+ )} + + setSelectedId(undefined)} + /> +
+ ) +} + +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 ( + +
+ {draftQ.isLoading && } + {draftQ.error && ( + + {String(draftQ.error)} + + )} + + {draft && ( + <> +
+ {draft.operation} + {draft.businessKey} + + {t('reviews.drawer.maker')}: {draft.makerId} + + + {t('reviews.drawer.submitted')}:{' '} + {new Date(draft.submittedAt).toLocaleString()} + +
+ + {draft.makerComment && ( +
+ + {t('reviews.drawer.makerComment')}: + {' '} + {draft.makerComment} +
+ )} + +
+
+

+ {t('reviews.drawer.live')} +

+ {liveQ.isLoading && } + {liveQ.data === null && ( +

+ {t('reviews.drawer.noLive')} +

+ )} + {liveQ.data && ( +
+                    {JSON.stringify(liveQ.data.data, null, 2)}
+                  
+ )} +
+
+

+ {t('reviews.drawer.proposed')} +

+ {draft.operation === 'CLOSE' ? ( +

+ {t('reviews.drawer.closeNote')} +

+ ) : ( +
+                    {JSON.stringify(draft.data, null, 2)}
+                  
+ )} +
+
+ + {(approveMut.error || rejectMut.error) && ( + + {String(approveMut.error || rejectMut.error)} + + )} + +
+ + setComment(e.target.value)} + placeholder={t('reviews.drawer.commentPlaceholder')} + /> +
+ + + +
+
+ + )} +
+
+ ) +}