diff --git a/ordinis-admin-ui/src/auth/usePermissions.ts b/ordinis-admin-ui/src/auth/usePermissions.ts
new file mode 100644
index 0000000..24391e3
--- /dev/null
+++ b/ordinis-admin-ui/src/auth/usePermissions.ts
@@ -0,0 +1,41 @@
+import { useAuth } from 'react-oidc-context'
+
+/**
+ * Permission helpers для schema workflow. Phase 1c — stub: проверяем
+ * только что пользователь authenticated (любой logged-in юзер может
+ * approve/publish). Backend всё равно отрежет:
+ *
+ * {@code 403 self_approve_forbidden} — если maker == reviewer.
+ * {@code 403 forbidden_role} — когда Keycloak roles прорастут и
+ * backend начнёт проверять realm-роли (placeholder).
+ *
+ *
+ * Phase 1d: подключим decode JWT → realm_access.roles, и эти hook'и
+ * начнут возвращать false для users без \`ordinis-schema-reviewer\` /
+ * \`ordinis-schema-publisher\` ролей. UI hide'ит соответствующие кнопки.
+ *
+ *
До тех пор все logged-in юзеры могут жать любые workflow buttons —
+ * backend гейт остаётся primary defence.
+ */
+
+/** Может ли актор approve / request_changes / reject schema draft. */
+export function useCanReviewSchema(): boolean {
+ const auth = useAuth()
+ // TODO Phase 1d: const roles = (auth.user?.profile as { realm_access?: { roles?: string[] } })?.realm_access?.roles ?? []
+ // return roles.includes('ordinis-schema-reviewer')
+ return auth.isAuthenticated
+}
+
+/** Может ли актор publish approved schema draft (bump live version). */
+export function useCanPublishSchema(): boolean {
+ const auth = useAuth()
+ // TODO Phase 1d: roles.includes('ordinis-schema-publisher')
+ return auth.isAuthenticated
+}
+
+/** Может ли актор create schema draft (maker side). */
+export function useCanCreateSchemaDraft(): boolean {
+ const auth = useAuth()
+ // TODO Phase 1d: roles.includes('ordinis-schema-maker') OR any authenticated
+ return auth.isAuthenticated
+}
diff --git a/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx b/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx
new file mode 100644
index 0000000..8ba1c2b
--- /dev/null
+++ b/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx
@@ -0,0 +1,238 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import axios from 'axios'
+import {
+ Alert,
+ Button,
+ Modal,
+ TextArea,
+ toast,
+} from '@/ui'
+import { ArrowsClockwiseIcon, CheckSquareIcon } from '@phosphor-icons/react'
+import type { DictionaryDetail } from '@/api/client'
+import { useCreateSchemaDraft } from '@/api/mutations'
+
+type Props = {
+ open: boolean
+ onClose: () => void
+ detail: DictionaryDetail
+ /** Optional callback после успешного create — parent может закрыть modal +
+ * открыть SchemaDraftDrawer чтоб maker сразу submit'нул. */
+ onCreated?: (draftId: string) => void
+}
+
+/**
+ * Создание schema draft'а с editor'ом proposed schema. Минимальная версия:
+ * JSON textarea с {@code Format} и {@code Validate} buttons. Phase 1d
+ * может upgrade'ить до Monaco / visual schema editor.
+ *
+ *
Initial value seeded из текущего {@code detail.schemaJson}. User editing
+ * правит proposedSchema, добавляет {@code reason}, submit'ит.
+ *
+ *
Backend errors surface'ятся как toast:
+ *
+ * 409 {@code version_mismatch} — кто-то опубликовал свой draft пока
+ * этот пользователь готовился. Refresh page + retry.
+ * 409 {@code active_draft_exists} — уже есть non-terminal draft.
+ * Пользователь должен open existing.
+ * 400 {@code invalid_schema} — proposedSchema не валидна. Backend
+ * вернёт detail в message.
+ *
+ */
+export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Props) {
+ const { t } = useTranslation()
+ const initialJson = useMemo(
+ () => JSON.stringify(detail.schemaJson, null, 2),
+ [detail.schemaJson],
+ )
+ const [json, setJson] = useState(initialJson)
+ const [reason, setReason] = useState('')
+ const [parseError, setParseError] = useState(null)
+
+ // Reset state при re-open чтобы не показывать stale данные с прошлого dict'а.
+ useEffect(() => {
+ if (open) {
+ setJson(initialJson)
+ setReason('')
+ setParseError(null)
+ }
+ }, [open, initialJson])
+
+ const createMut = useCreateSchemaDraft(detail.name)
+
+ const handleFormat = () => {
+ try {
+ const parsed = JSON.parse(json)
+ setJson(JSON.stringify(parsed, null, 2))
+ setParseError(null)
+ } catch (e) {
+ setParseError((e as Error).message)
+ }
+ }
+
+ const handleValidate = () => {
+ try {
+ const parsed = JSON.parse(json)
+ if (typeof parsed !== 'object' || parsed === null) {
+ setParseError(
+ t('createDraft.notObject', {
+ defaultValue: 'Schema должна быть JSON object',
+ }),
+ )
+ return false
+ }
+ setParseError(null)
+ toast.success(
+ t('createDraft.validationPassed', { defaultValue: 'JSON валиден' }),
+ )
+ return true
+ } catch (e) {
+ setParseError((e as Error).message)
+ return false
+ }
+ }
+
+ const handleSubmit = () => {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(json)
+ } catch (e) {
+ setParseError((e as Error).message)
+ return
+ }
+ createMut.mutate(
+ {
+ fromVersion: detail.schemaVersion,
+ reason: reason || undefined,
+ proposedSchema: parsed,
+ },
+ {
+ onSuccess: (draft) => {
+ toast.success(
+ t('createDraft.toast.created', { defaultValue: 'Черновик создан' }),
+ )
+ onCreated?.(draft.draftId)
+ onClose()
+ },
+ onError: (err) => {
+ let msg = t('createDraft.toast.failed', {
+ defaultValue: 'Не удалось создать черновик',
+ })
+ if (axios.isAxiosError(err)) {
+ const body = err.response?.data as
+ | { code?: string; message?: string }
+ | undefined
+ msg = body?.message ?? body?.code ?? msg
+ }
+ toast.error(msg)
+ },
+ },
+ )
+ }
+
+ return (
+
+
+
+ {t('createDraft.intro', {
+ defaultValue:
+ 'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
+ })}
+
+
+ {/* Reason */}
+
+
+ {t('createDraft.reason', { defaultValue: 'Причина изменений' })}
+
+
+
+ {/* JSON Schema editor */}
+
+
+
+ {t('createDraft.schema', { defaultValue: 'Схема (JSON)' })}
+
+
+ }
+ >
+ {t('createDraft.format', { defaultValue: 'Format' })}
+
+ }
+ >
+ {t('createDraft.validate', { defaultValue: 'Validate' })}
+
+
+
+ {/* Custom monospace textarea — для full Monaco editor'а Phase 1d.
+ Высота min-h-[400px] чтобы средняя schema ~30-50 полей помещалась
+ без скролла. */}
+
+
+ {/* Footer actions */}
+
+
+ {t('common.cancel', { defaultValue: 'Отмена' })}
+
+
+ {createMut.isPending
+ ? t('createDraft.creating', { defaultValue: 'Создание…' })
+ : t('createDraft.submit', { defaultValue: 'Создать черновик' })}
+
+
+
+
+ )
+}
diff --git a/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx b/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx
index bd31e2a..0b089dd 100644
--- a/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx
+++ b/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx
@@ -23,6 +23,7 @@ import {
useSubmitSchemaDraftForReview,
useWithdrawSchemaDraft,
} from '@/api/mutations'
+import { useCanPublishSchema, useCanReviewSchema } from '@/auth/usePermissions'
type Props = {
open: boolean
@@ -391,6 +392,11 @@ function DraftActions({
hasCurrentVersion: boolean
}) {
const { t } = useTranslation()
+ // Phase 1c RBAC stub: для review_pending reviewer-only действия скрываем
+ // если нет approve role. Publish — отдельная role. Backend всё равно
+ // защищает. Phase 1d intergrate'нет Keycloak realm_access.roles.
+ const canReview = useCanReviewSchema()
+ const canPublish = useCanPublishSchema()
if (!isActiveSchemaDraft(status)) {
return (
@@ -421,7 +427,7 @@ function DraftActions({
})}
)}
- {status === 'review_pending' && (
+ {status === 'review_pending' && canReview && (
<>
>
)}
- {status === 'approved' && (
+ {status === 'approved' && canPublish && (
(undefined)
const [aoiOpen, setAoiOpen] = useState(false)
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
@@ -703,6 +714,21 @@ function DictionaryDetail() {
actions={
canMutate ? (
<>
+ {/* approval-required dict без active draft: предлагаем
+ создать черновик. Direct edit через GearIcon оставляем
+ для админов, но primary path — через workflow. */}
+ {shouldOfferCreateDraft && (
+ }
+ disabled={!detailQuery.data}
+ onClick={() => setCreateDraftOpen(true)}
+ />
+ )}
)}
+ {detailQuery.data && (
+ setCreateDraftOpen(false)}
+ detail={detailQuery.data}
+ />
+ )}
+
setHistoryKey(undefined)}
diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx
index 7640104..171ee20 100644
--- a/ordinis-admin-ui/src/routes/reviews.tsx
+++ b/ordinis-admin-ui/src/routes/reviews.tsx
@@ -22,7 +22,7 @@ import {
TextInput,
} from '@/ui'
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
-import { useDraft, useLiveRecord, useReviewQueue } from '@/api/queries'
+import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } from '@/api/queries'
import { useApproveDraft, useRejectDraft } from '@/api/mutations'
import type { DraftOperation, DraftResponse } from '@/api/client'
@@ -142,6 +142,8 @@ function ReviewsPage() {
description={t('reviews.description')}
/>
+
+
{queue.isLoading && }
{queue.error && (
@@ -507,3 +509,80 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
)
}
+
+/**
+ * Schema review pool queue — global view всех \`review_pending\` schema
+ * draft'ов через {@code GET /admin/schema-reviews/pending}. Reviewer
+ * жмёт «Открыть» → переходит на dict detail page, banner+drawer
+ * автоматически подхватятся через {@code useDictActiveSchemaDraft}.
+ *
+ * Скрывается если pool пуст — экономит вертикальное место на странице
+ * для основного record-review queue.
+ */
+function SchemaReviewQueuePanel() {
+ const { t } = useTranslation()
+ const queue = useSchemaReviewQueue(0, 20)
+ const items = queue.data?.items ?? []
+ if (queue.isLoading || items.length === 0) return null
+
+ return (
+
+
+
+
+ {t('reviews.schema.title', { defaultValue: 'Изменения схем' })}
+
+
+ {t('reviews.schema.queueTotal', {
+ count: queue.data?.total ?? items.length,
+ defaultValue: `${queue.data?.total ?? items.length} draft(s) ждут ревью`,
+ })}
+
+
+
+ {t('reviews.schema.label', { defaultValue: 'Schema workflow' })}
+
+
+
+
+
+ {t('reviews.schema.dictionary', { defaultValue: 'Словарь' })}
+ {t('reviews.schema.fromVersion', { defaultValue: 'От версии' })}
+ {t('reviews.schema.maker', { defaultValue: 'Автор' })}
+ {t('reviews.schema.submittedAt', { defaultValue: 'Отправлен' })}
+ {t('reviews.schema.action', { defaultValue: 'Действие' })}
+
+
+
+ {items.map((d) => (
+
+
+
+ {d.dictionaryName}
+
+
+ v{d.branchedFromVersion}
+ {d.makerId}
+
+ {d.submittedAt ? new Date(d.submittedAt).toLocaleString() : '—'}
+
+
+
+ {t('reviews.schema.open', { defaultValue: 'Открыть' })} →
+
+
+
+ ))}
+
+
+
+ )
+}