From dd3a2d97e53fa1edf7520a921ebc07d431b406a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Tue, 12 May 2026 11:08:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(schema-workflow):=20Phase=201b=20=E2=80=94?= =?UTF-8?q?=20SchemaDraftDrawer=20with=20status-aware=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ordinis-admin-ui/src/api/client.ts | 94 ++++ ordinis-admin-ui/src/api/mutations.ts | 136 +++++ ordinis-admin-ui/src/api/queries.ts | 97 ++++ .../src/components/editor/WorkflowBanner.tsx | 107 +++- .../components/workflow/SchemaDraftDrawer.tsx | 528 ++++++++++++++++++ ordinis-admin-ui/src/i18n.ts | 106 ++++ 6 files changed, 1065 insertions(+), 3 deletions(-) create mode 100644 ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 0071f8a..671173e 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -542,6 +542,100 @@ export type ChangelogResponse = { * definition_create. Для draft_* (не publish) — after=proposed_schema, * before=live schema на момент event'а (то что предлагалось vs реальность). */ +// === Schema workflow (maker-checker drafts, v2.2.0 backend) === + +/** + * Server возвращает status в lowercase ({@code "draft"}, {@code "review_pending"}, + * ...) — match'им backend dbValue() напрямую, без перевода в frontend. + * + * Terminal: REJECTED, PUBLISHED, WITHDRAWN. + * Active: DRAFT, REVIEW_PENDING, APPROVED, CHANGES_REQUESTED. + */ +export type SchemaDraftStatus = + | 'draft' + | 'review_pending' + | 'approved' + | 'changes_requested' + | 'rejected' + | 'published' + | 'withdrawn' + +export const TERMINAL_SCHEMA_DRAFT_STATUSES: ReadonlySet = new Set([ + 'rejected', + 'published', + 'withdrawn', +]) + +export const isActiveSchemaDraft = (s: SchemaDraftStatus): boolean => + !TERMINAL_SCHEMA_DRAFT_STATUSES.has(s) + +export type SchemaDraft = { + draftId: string + dictionaryId: string + dictionaryName: string + status: SchemaDraftStatus + branchedFromVersion: string + /** Full proposed JSON Schema. */ + proposedSchema: unknown + reason?: string | null + makerId: string + createdAt: string + submittedAt?: string | null + /** Optional reviewer list (NULL = open pool). */ + reviewers?: unknown | null + reviewNote?: string | null + decisionUserId?: string | null + decisionAt?: string | null + decisionComment?: string | null + publishedAt?: string | null + publishedVersion?: string | null + publishNote?: string | null + /** JPA optimistic lock counter. */ + version: number +} + +export type CreateSchemaDraftRequest = { + /** Current HEAD schema version this draft branches from. */ + fromVersion: string + reason?: string + /** Full proposed JSON Schema (replace, not patch). */ + proposedSchema: unknown +} + +export type SubmitForReviewRequest = { + /** Optional explicit reviewer IDs. NULL/[] = pool. */ + reviewers?: string[] | null + note?: string | null +} + +export type SchemaDraftVerdict = 'APPROVE' | 'REQUEST_CHANGES' | 'REJECT' + +export type DecisionRequest = { + decision: SchemaDraftVerdict + comment?: string +} + +export type PublishSchemaDraftRequest = { + /** Optimistic lock — current schema version от UI. */ + expectedHeadVersion: string + publishNote?: string +} + +/** Response для POST /publish — wraps draft + computed bump. */ +export type PublishSchemaDraftResult = { + draft: SchemaDraft + newVersion: string + publishedAt: string +} + +/** Pool queue для /admin/schema-reviews/pending. */ +export type SchemaReviewQueuePage = { + items: SchemaDraft[] + page: number + size: number + total: number +} + export type ChangelogDiff = { id: number dictionaryName: string diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index 0c83c59..4be6d64 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -7,11 +7,17 @@ import { type CascadeCloseResult, type CreateDictionaryRequest, type CreateRecordRequest, + type CreateSchemaDraftRequest, type CreateWebhookSubscriptionRequest, + type DecisionRequest, type DictionaryDetail, type DraftResponse, + type PublishSchemaDraftRequest, + type PublishSchemaDraftResult, type RecordResponse, + type SchemaDraft, type SubmitDraftRequest, + type SubmitForReviewRequest, type WebhookDelivery, type WebhookSubscription, type WebhookTestPingResult, @@ -480,6 +486,136 @@ export const useWithdrawDraft = () => { }) } +// === Schema workflow mutations (Phase 1a, v2.2.0 backend) ============= + +/** + * Создаёт schema draft от current HEAD. Backend проверяет fromVersion + * против live schemaVersion (409 version_mismatch если расходится). + * Также 409 active_draft_exists если на dict'е уже есть non-terminal draft. + * + *

onSuccess invalidates: active-draft query (banner picks up) + + * drafts list + reviewer pool queue. + */ +export const useCreateSchemaDraft = (dictionaryName: string) => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (payload: CreateSchemaDraftRequest): Promise => { + const { data } = await apiClient.post( + `/dictionaries/${dictionaryName}/drafts`, + payload, + ) + return data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-review-queue'] }) + }, + }) +} + +/** Maker submits draft → review_pending. 202 Accepted. */ +export const useSubmitSchemaDraftForReview = (dictionaryName: string) => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (params: { + draftId: string + body?: SubmitForReviewRequest + }): Promise => { + const { data } = await apiClient.post( + `/dictionaries/${dictionaryName}/drafts/${params.draftId}/review`, + params.body ?? {}, + ) + return data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-review-queue'] }) + }, + }) +} + +/** + * Reviewer decide: approve / request_changes / reject. Backend + * проверяет self_approve_forbidden (maker_id != actor) → 403. + */ +export const useDecideSchemaDraft = (dictionaryName: string) => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (params: { + draftId: string + body: DecisionRequest + }): Promise => { + const { data } = await apiClient.post( + `/dictionaries/${dictionaryName}/drafts/${params.draftId}/decision`, + params.body, + ) + return data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-review-queue'] }) + // Changelog gets new entry (draft_approve / draft_reject / draft_changes_requested) + qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] }) + }, + }) +} + +/** + * Publish approved draft → bump live HEAD. Backend computes new version + * (minor default, major на breaking). Returns wrapped {draft, newVersion, publishedAt}. + * + *

onSuccess invalidates dict detail (schema/version changed) + changelog + + * draft queries. + */ +export const usePublishSchemaDraft = (dictionaryName: string) => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (params: { + draftId: string + body: PublishSchemaDraftRequest + }): Promise => { + const { data } = await apiClient.post( + `/dictionaries/${dictionaryName}/drafts/${params.draftId}/publish`, + params.body, + ) + return data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-review-queue'] }) + qc.invalidateQueries({ queryKey: ['dictionary', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] }) + }, + }) +} + +/** Maker withdraws non-terminal draft (terminal state). */ +export const useWithdrawSchemaDraft = (dictionaryName: string) => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (draftId: string): Promise => { + const { data } = await apiClient.delete( + `/dictionaries/${dictionaryName}/drafts/${draftId}`, + ) + return data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] }) + qc.invalidateQueries({ queryKey: ['schema-review-queue'] }) + qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] }) + }, + }) +} + 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 b4286ed..2a50bc8 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -1,4 +1,5 @@ import { useQuery, queryOptions } from '@tanstack/react-query' +import axios from 'axios' import { apiClient, type AuditFilters, @@ -6,6 +7,8 @@ import { type CascadePlan, type ChangelogDiff, type ChangelogResponse, + type SchemaDraft, + type SchemaReviewQueuePage, type DictionaryDefinition, type DictionaryDetail, type DlqPage, @@ -192,6 +195,100 @@ export const useChangelogDiff = ( enabled: Boolean(dictionaryName) && Boolean(entryId), }) +// === Schema workflow drafts (Phase 1a) ================================ + +/** + * Active schema draft на словаре, если есть. Backend возвращает 404 + * {@code no_active_draft} когда нет non-terminal draft'а — мы маппим + * это в null чтобы UI рендерил «no draft» state без error toast'а. + * + *

refetchInterval=20s — banner должен быстро отражать «отправили + * на ревью» / «approved» transition от другого пользователя. + */ +export const dictActiveSchemaDraftQuery = (dictName: string) => + queryOptions({ + queryKey: ['schema-draft-active', dictName] as const, + queryFn: async (): Promise => { + try { + const { data } = await apiClient.get( + `/dictionaries/${dictName}/drafts/active`, + ) + return data + } catch (err) { + // 404 no_active_draft — это not-an-error, отдаём null. + if (axios.isAxiosError(err) && err.response?.status === 404) { + return null + } + throw err + } + }, + refetchInterval: 20_000, + }) + +export const useDictActiveSchemaDraft = (dictName: string | undefined) => + useQuery({ + ...dictActiveSchemaDraftQuery(dictName ?? ''), + enabled: Boolean(dictName), + }) + +/** History всех schema drafts на словаре (для admin audit / settings tab). */ +export const dictSchemaDraftsQuery = (dictName: string) => + queryOptions({ + queryKey: ['schema-drafts', dictName] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + `/dictionaries/${dictName}/drafts`, + ) + return data + }, + }) + +export const useDictSchemaDrafts = (dictName: string | undefined) => + useQuery({ + ...dictSchemaDraftsQuery(dictName ?? ''), + enabled: Boolean(dictName), + }) + +/** Single draft drill-in (decision modal, deep-link). */ +export const schemaDraftQuery = (dictName: string, draftId: string) => + queryOptions({ + queryKey: ['schema-draft', dictName, draftId] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + `/dictionaries/${dictName}/drafts/${draftId}`, + ) + return data + }, + }) + +export const useSchemaDraft = ( + dictName: string | undefined, + draftId: string | undefined, +) => + useQuery({ + ...schemaDraftQuery(dictName ?? '', draftId ?? ''), + enabled: Boolean(dictName) && Boolean(draftId), + }) + +/** + * Global pool queue для approvers — все review_pending drafts. + * Backend response: {items, page, size, total}. + */ +export const schemaReviewQueueQuery = (page = 0, size = 50) => + queryOptions({ + queryKey: ['schema-review-queue', page, size] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + '/admin/schema-reviews/pending', + { params: { page, size } }, + ) + return data + }, + }) + +export const useSchemaReviewQueue = (page = 0, size = 50) => + useQuery(schemaReviewQueueQuery(page, size)) + export const recordRawQuery = (dictionaryName: string, businessKey: string) => queryOptions({ queryKey: ['record-raw', dictionaryName, businessKey] as const, diff --git a/ordinis-admin-ui/src/components/editor/WorkflowBanner.tsx b/ordinis-admin-ui/src/components/editor/WorkflowBanner.tsx index bd082da..743bc50 100644 --- a/ordinis-admin-ui/src/components/editor/WorkflowBanner.tsx +++ b/ordinis-admin-ui/src/components/editor/WorkflowBanner.tsx @@ -1,8 +1,11 @@ +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Link } from '@tanstack/react-router' -import { ClipboardList, FileText } from 'lucide-react' -import type { DictionaryDetail } from '@/api/client' +import { ClipboardList, FileText, GitBranch } from 'lucide-react' +import type { DictionaryDetail, SchemaDraft, SchemaDraftStatus } from '@/api/client' +import { useDictActiveSchemaDraft } from '@/api/queries' import { cn } from '@/lib/utils' +import { SchemaDraftDrawer } from '@/components/workflow/SchemaDraftDrawer' /** * WorkflowBanner — status banner для editor (Screen 2 per handoff). @@ -32,8 +35,18 @@ export type WorkflowBannerProps = { export function WorkflowBanner({ detail, pendingCount = 0 }: WorkflowBannerProps) { const { t } = useTranslation() + // Phase 1a: read-side active schema draft. Если на dict'е есть + // non-terminal schema draft — surface'им его state выше record-approval + // banner'а (schema draft = higher salience: меняет структуру всего dict'а, + // record approval — per-row). + const { data: schemaDraft } = useDictActiveSchemaDraft(detail.name) - // === Case 1: Live — no approval required === + // === Case A: Active schema draft — highest priority === + if (schemaDraft) { + return + } + + // === Case 1: Live — no approval required (and no schema draft) === // Постоянный "Опубликовано" banner = шум. Status уже виден в InfoPanel // (scope, version, updatedAt). Не показываем banner вообще. if (!detail.approvalRequired) { @@ -102,6 +115,94 @@ const VARIANT_CLASS: Record = { draft: 'bg-warn-bg border-l-warn text-ink', } +/** + * Schema-level draft banner — surface'им active non-terminal draft каждому + * пользователю который заходит в editor словаря. Tone зависит от status'а: + * + *

+ * + *

Phase 1a: read-only banner. Кнопки «Отправить», «Approve», «Publish» + * проводным появятся в Phase 1b (модалки + RBAC role check). Сейчас просто + * link на draft detail (когда такая страница появится — placeholder). + */ +function SchemaDraftBanner({ + draft, + detail, +}: { + draft: SchemaDraft + detail: DictionaryDetail +}) { + const { t } = useTranslation() + const [drawerOpen, setDrawerOpen] = useState(false) + const variant = schemaDraftVariant(draft.status) + const labelKey = `workflow.schemaDraft.${draft.status}` + const fallbackLabel: Record = { + draft: 'Черновик схемы создан', + review_pending: 'Черновик схемы на ревью', + approved: 'Черновик схемы одобрен', + changes_requested: 'По черновику запрошены правки', + rejected: 'Черновик схемы отклонён', + published: 'Черновик опубликован', + withdrawn: 'Черновик отозван', + } + return ( + <> + } + action={ + + } + > + + {t(labelKey, { defaultValue: fallbackLabel[draft.status] })} + + + {t('workflow.schemaDraft.subtitle', { + fromVersion: draft.branchedFromVersion, + defaultValue: `от v${draft.branchedFromVersion}`, + })} + + + setDrawerOpen(false)} + dictionaryName={detail.name} + currentHeadVersion={detail.schemaVersion} + /> + + ) +} + +function schemaDraftVariant(status: SchemaDraftStatus): BannerProps['variant'] { + switch (status) { + case 'draft': + case 'changes_requested': + return 'draft' + case 'review_pending': + return 'review' + case 'approved': + return 'live' + case 'rejected': + case 'withdrawn': + case 'published': + // Terminal states обычно не должны попасть сюда (useDictActiveSchemaDraft + // фильтрует non-terminal), но если backend пометил draft active'ом — + // показываем neutral info. + return 'info' + } +} + function Banner({ variant, icon, action, children }: BannerProps) { return (

void + dictionaryName: string + /** Current live schemaVersion — passed для publish expectedHeadVersion check. */ + currentHeadVersion: string | undefined +} + +/** + * Right-side drawer для schema draft — full state + status-aware actions. + * + *

Загружает активный draft через {@link useDictActiveSchemaDraft}. + * Comment textarea используется как: + *

    + *
  • {@code review_pending} — reviewer пишет decision comment (approve/ + * request_changes/reject).
  • + *
  • {@code draft / changes_requested} — maker note при submit.
  • + *
  • {@code approved} — publish release note.
  • + *
+ * + *

Все действия идут через mutations из {@code @/api/mutations}. + * Backend сам отрезает невалидные transitions (403 self_approve_forbidden, + * 409 invalid_transition, 409 version_mismatch) — мы показываем error + * toast с raw message. + * + *

Phase 1b: maker И reviewer actions показаны всем (backend гейтит). + * Phase 1c integration RBAC из Keycloak roles claim сузит видимость. + */ +export function SchemaDraftDrawer({ + open, + onClose, + dictionaryName, + currentHeadVersion, +}: Props) { + const { t } = useTranslation() + const [comment, setComment] = useState('') + const { data: draft, isLoading, error } = useDictActiveSchemaDraft( + open ? dictionaryName : undefined, + ) + + const submitMut = useSubmitSchemaDraftForReview(dictionaryName) + const decideMut = useDecideSchemaDraft(dictionaryName) + const publishMut = usePublishSchemaDraft(dictionaryName) + const withdrawMut = useWithdrawSchemaDraft(dictionaryName) + + const handleError = (err: unknown, fallback: string) => { + let msg = fallback + if (axios.isAxiosError(err)) { + const body = err.response?.data + if (body && typeof body === 'object') { + const code = (body as { code?: string }).code + const message = (body as { message?: string }).message + msg = message ?? code ?? fallback + } + } + toast.error(msg) + } + + const onSubmitReview = () => { + if (!draft) return + submitMut.mutate( + { draftId: draft.draftId, body: { note: comment || null } }, + { + onSuccess: () => { + toast.success( + t('workflow.schemaDraft.toast.submitted', { + defaultValue: 'Отправлено на ревью', + }), + ) + setComment('') + }, + onError: (e) => + handleError(e, t('workflow.schemaDraft.toast.submitFailed', { + defaultValue: 'Не удалось отправить на ревью', + })), + }, + ) + } + + const onDecide = (verdict: SchemaDraftVerdict) => { + if (!draft) return + decideMut.mutate( + { draftId: draft.draftId, body: { decision: verdict, comment: comment || undefined } }, + { + onSuccess: () => { + toast.success( + t(`workflow.schemaDraft.toast.${verdict.toLowerCase()}`, { + defaultValue: verdictSuccessFallback(verdict), + }), + ) + setComment('') + }, + onError: (e) => + handleError(e, t('workflow.schemaDraft.toast.decideFailed', { + defaultValue: 'Не удалось зафиксировать решение', + })), + }, + ) + } + + const onPublish = () => { + if (!draft || !currentHeadVersion) return + publishMut.mutate( + { + draftId: draft.draftId, + body: { + expectedHeadVersion: currentHeadVersion, + publishNote: comment || undefined, + }, + }, + { + onSuccess: (res) => { + toast.success( + t('workflow.schemaDraft.toast.published', { + version: res.newVersion, + defaultValue: `Опубликовано v${res.newVersion}`, + }), + ) + setComment('') + onClose() + }, + onError: (e) => + handleError(e, t('workflow.schemaDraft.toast.publishFailed', { + defaultValue: 'Не удалось опубликовать', + })), + }, + ) + } + + const onWithdraw = () => { + if (!draft) return + const ok = window.confirm( + t('workflow.schemaDraft.confirmWithdraw', { + defaultValue: 'Отозвать черновик? Действие нельзя отменить.', + }) as string, + ) + if (!ok) return + withdrawMut.mutate(draft.draftId, { + onSuccess: () => { + toast.success( + t('workflow.schemaDraft.toast.withdrawn', { + defaultValue: 'Черновик отозван', + }), + ) + onClose() + }, + onError: (e) => + handleError(e, t('workflow.schemaDraft.toast.withdrawFailed', { + defaultValue: 'Не удалось отозвать', + })), + }) + } + + const anyPending = + submitMut.isPending || + decideMut.isPending || + publishMut.isPending || + withdrawMut.isPending + + return ( + +

+ {isLoading && } + {error && ( + + {String(error)} + + )} + {!isLoading && !draft && ( +

+ {t('workflow.schemaDraft.empty', { + defaultValue: 'Активного черновика схемы нет.', + })} +

+ )} + + {draft && ( + <> + + + + {/* Read-only proposed schema. Edit flow (CreateModal) — отдельный + cliff в Phase 1c. Здесь просто видим что предлагается. */} + + + {/* Comment textarea. Один текстовое поле, semantics зависит от + status'а (см. drawer-level JSDoc). */} +
+ +