feat(schema-workflow): Phase 1c — CreateModal + /reviews extension + RBAC stub
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
import { useAuth } from 'react-oidc-context'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission helpers для schema workflow. Phase 1c — stub: проверяем
|
||||||
|
* только что пользователь authenticated (любой logged-in юзер может
|
||||||
|
* approve/publish). Backend всё равно отрежет:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code 403 self_approve_forbidden} — если maker == reviewer.</li>
|
||||||
|
* <li>{@code 403 forbidden_role} — когда Keycloak roles прорастут и
|
||||||
|
* backend начнёт проверять realm-роли (placeholder).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Phase 1d: подключим decode JWT → realm_access.roles, и эти hook'и
|
||||||
|
* начнут возвращать false для users без \`ordinis-schema-reviewer\` /
|
||||||
|
* \`ordinis-schema-publisher\` ролей. UI hide'ит соответствующие кнопки.
|
||||||
|
*
|
||||||
|
* <p>До тех пор все 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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Initial value seeded из текущего {@code detail.schemaJson}. User editing
|
||||||
|
* правит proposedSchema, добавляет {@code reason}, submit'ит.
|
||||||
|
*
|
||||||
|
* <p>Backend errors surface'ятся как toast:
|
||||||
|
* <ul>
|
||||||
|
* <li>409 {@code version_mismatch} — кто-то опубликовал свой draft пока
|
||||||
|
* этот пользователь готовился. Refresh page + retry.</li>
|
||||||
|
* <li>409 {@code active_draft_exists} — уже есть non-terminal draft.
|
||||||
|
* Пользователь должен open existing.</li>
|
||||||
|
* <li>400 {@code invalid_schema} — proposedSchema не валидна. Backend
|
||||||
|
* вернёт detail в message.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
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<string | null>(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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
size="xl"
|
||||||
|
title={t('createDraft.title', { defaultValue: 'Создать черновик схемы' })}
|
||||||
|
description={`${detail.name} · v${detail.schemaVersion}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert variant="info">
|
||||||
|
{t('createDraft.intro', {
|
||||||
|
defaultValue:
|
||||||
|
'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
|
||||||
|
})}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Reason */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-cap text-mute font-display uppercase tracking-wider mb-1">
|
||||||
|
{t('createDraft.reason', { defaultValue: 'Причина изменений' })}
|
||||||
|
</label>
|
||||||
|
<TextArea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder={t('createDraft.reasonPlaceholder', {
|
||||||
|
defaultValue: 'Зачем эти изменения нужны (видно ревьюеру)',
|
||||||
|
})}
|
||||||
|
rows={2}
|
||||||
|
disabled={createMut.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON Schema editor */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-cap text-mute font-display uppercase tracking-wider">
|
||||||
|
{t('createDraft.schema', { defaultValue: 'Схема (JSON)' })}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleFormat}
|
||||||
|
disabled={createMut.isPending}
|
||||||
|
leftIcon={<ArrowsClockwiseIcon weight="regular" size={14} />}
|
||||||
|
>
|
||||||
|
{t('createDraft.format', { defaultValue: 'Format' })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleValidate}
|
||||||
|
disabled={createMut.isPending}
|
||||||
|
leftIcon={<CheckSquareIcon weight="regular" size={14} />}
|
||||||
|
>
|
||||||
|
{t('createDraft.validate', { defaultValue: 'Validate' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Custom monospace textarea — для full Monaco editor'а Phase 1d.
|
||||||
|
Высота min-h-[400px] чтобы средняя schema ~30-50 полей помещалась
|
||||||
|
без скролла. */}
|
||||||
|
<textarea
|
||||||
|
value={json}
|
||||||
|
onChange={(e) => {
|
||||||
|
setJson(e.target.value)
|
||||||
|
if (parseError) setParseError(null)
|
||||||
|
}}
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={createMut.isPending}
|
||||||
|
className="w-full min-h-[400px] max-h-[60vh] text-mono text-cell rounded-md border border-line bg-[#0c0a23] text-[#f0eaff] p-3 focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
{parseError && (
|
||||||
|
<Alert variant="error" className="mt-2">
|
||||||
|
<span className="font-mono text-cell">{parseError}</span>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer actions */}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-line">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={createMut.isPending}
|
||||||
|
>
|
||||||
|
{t('common.cancel', { defaultValue: 'Отмена' })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={createMut.isPending || Boolean(parseError)}
|
||||||
|
>
|
||||||
|
{createMut.isPending
|
||||||
|
? t('createDraft.creating', { defaultValue: 'Создание…' })
|
||||||
|
: t('createDraft.submit', { defaultValue: 'Создать черновик' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
useSubmitSchemaDraftForReview,
|
useSubmitSchemaDraftForReview,
|
||||||
useWithdrawSchemaDraft,
|
useWithdrawSchemaDraft,
|
||||||
} from '@/api/mutations'
|
} from '@/api/mutations'
|
||||||
|
import { useCanPublishSchema, useCanReviewSchema } from '@/auth/usePermissions'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -391,6 +392,11 @@ function DraftActions({
|
|||||||
hasCurrentVersion: boolean
|
hasCurrentVersion: boolean
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
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)) {
|
if (!isActiveSchemaDraft(status)) {
|
||||||
return (
|
return (
|
||||||
@@ -421,7 +427,7 @@ function DraftActions({
|
|||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{status === 'review_pending' && (
|
{status === 'review_pending' && canReview && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -454,7 +460,7 @@ function DraftActions({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status === 'approved' && (
|
{status === 'approved' && canPublish && (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -476,6 +476,33 @@ i18n
|
|||||||
'workflow.schemaDraft.toast.decideFailed': 'Не удалось зафиксировать решение',
|
'workflow.schemaDraft.toast.decideFailed': 'Не удалось зафиксировать решение',
|
||||||
'workflow.schemaDraft.toast.publishFailed': 'Не удалось опубликовать',
|
'workflow.schemaDraft.toast.publishFailed': 'Не удалось опубликовать',
|
||||||
'workflow.schemaDraft.toast.withdrawFailed': 'Не удалось отозвать',
|
'workflow.schemaDraft.toast.withdrawFailed': 'Не удалось отозвать',
|
||||||
|
// === CreateSchemaDraftModal ===
|
||||||
|
'createDraft.title': 'Создать черновик схемы',
|
||||||
|
'createDraft.intro': 'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
|
||||||
|
'createDraft.openButton': 'Создать черновик схемы',
|
||||||
|
'createDraft.reason': 'Причина изменений',
|
||||||
|
'createDraft.reasonPlaceholder': 'Зачем эти изменения нужны (видно ревьюеру)',
|
||||||
|
'createDraft.schema': 'Схема (JSON)',
|
||||||
|
'createDraft.format': 'Format',
|
||||||
|
'createDraft.validate': 'Validate',
|
||||||
|
'createDraft.validationPassed': 'JSON валиден',
|
||||||
|
'createDraft.notObject': 'Schema должна быть JSON object',
|
||||||
|
'createDraft.submit': 'Создать черновик',
|
||||||
|
'createDraft.creating': 'Создание…',
|
||||||
|
'createDraft.toast.created': 'Черновик создан',
|
||||||
|
'createDraft.toast.failed': 'Не удалось создать черновик',
|
||||||
|
// === Schema review queue panel ===
|
||||||
|
'reviews.schema.title': 'Изменения схем',
|
||||||
|
'reviews.schema.label': 'Schema workflow',
|
||||||
|
'reviews.schema.queueTotal_one': '{{count}} draft ждёт ревью',
|
||||||
|
'reviews.schema.queueTotal_few': '{{count}} draft\'а ждут ревью',
|
||||||
|
'reviews.schema.queueTotal_many': '{{count}} draft\'ов ждут ревью',
|
||||||
|
'reviews.schema.dictionary': 'Словарь',
|
||||||
|
'reviews.schema.fromVersion': 'От версии',
|
||||||
|
'reviews.schema.maker': 'Автор',
|
||||||
|
'reviews.schema.submittedAt': 'Отправлен',
|
||||||
|
'reviews.schema.action': 'Действие',
|
||||||
|
'reviews.schema.open': 'Открыть',
|
||||||
// === Lineage (dict-relationships-v2 Phase 1) ===
|
// === Lineage (dict-relationships-v2 Phase 1) ===
|
||||||
'lineage.usedBy.title': 'На этот словарь ссылаются',
|
'lineage.usedBy.title': 'На этот словарь ссылаются',
|
||||||
'lineage.usedBy.count_one': '{{count}} связь',
|
'lineage.usedBy.count_one': '{{count}} связь',
|
||||||
@@ -1068,6 +1095,32 @@ i18n
|
|||||||
'workflow.schemaDraft.toast.decideFailed': 'Failed to record decision',
|
'workflow.schemaDraft.toast.decideFailed': 'Failed to record decision',
|
||||||
'workflow.schemaDraft.toast.publishFailed': 'Failed to publish',
|
'workflow.schemaDraft.toast.publishFailed': 'Failed to publish',
|
||||||
'workflow.schemaDraft.toast.withdrawFailed': 'Failed to withdraw',
|
'workflow.schemaDraft.toast.withdrawFailed': 'Failed to withdraw',
|
||||||
|
// === CreateSchemaDraftModal ===
|
||||||
|
'createDraft.title': 'Create schema draft',
|
||||||
|
'createDraft.intro': 'Changes are saved as a draft branched from the current version. Live schema is not touched until publish-after-review.',
|
||||||
|
'createDraft.openButton': 'Create schema draft',
|
||||||
|
'createDraft.reason': 'Reason for changes',
|
||||||
|
'createDraft.reasonPlaceholder': 'Why these changes are needed (visible to the reviewer)',
|
||||||
|
'createDraft.schema': 'Schema (JSON)',
|
||||||
|
'createDraft.format': 'Format',
|
||||||
|
'createDraft.validate': 'Validate',
|
||||||
|
'createDraft.validationPassed': 'JSON is valid',
|
||||||
|
'createDraft.notObject': 'Schema must be a JSON object',
|
||||||
|
'createDraft.submit': 'Create draft',
|
||||||
|
'createDraft.creating': 'Creating…',
|
||||||
|
'createDraft.toast.created': 'Draft created',
|
||||||
|
'createDraft.toast.failed': 'Failed to create draft',
|
||||||
|
// === Schema review queue panel ===
|
||||||
|
'reviews.schema.title': 'Schema changes',
|
||||||
|
'reviews.schema.label': 'Schema workflow',
|
||||||
|
'reviews.schema.queueTotal_one': '{{count}} draft awaiting review',
|
||||||
|
'reviews.schema.queueTotal_other': '{{count}} drafts awaiting review',
|
||||||
|
'reviews.schema.dictionary': 'Dictionary',
|
||||||
|
'reviews.schema.fromVersion': 'From version',
|
||||||
|
'reviews.schema.maker': 'Author',
|
||||||
|
'reviews.schema.submittedAt': 'Submitted',
|
||||||
|
'reviews.schema.action': 'Action',
|
||||||
|
'reviews.schema.open': 'Open',
|
||||||
// === Lineage (dict-relationships-v2 Phase 1) ===
|
// === Lineage (dict-relationships-v2 Phase 1) ===
|
||||||
'lineage.usedBy.title': 'Used by',
|
'lineage.usedBy.title': 'Used by',
|
||||||
'lineage.usedBy.count_one': '{{count}} reference',
|
'lineage.usedBy.count_one': '{{count}} reference',
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon } from '@phosphor-icons/react'
|
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon, GitBranchIcon } from '@phosphor-icons/react'
|
||||||
import {
|
import {
|
||||||
useAudit,
|
useAudit,
|
||||||
useChangelog,
|
useChangelog,
|
||||||
|
useDictActiveSchemaDraft,
|
||||||
useDictionaryDetail,
|
useDictionaryDetail,
|
||||||
useDictPendingDrafts,
|
useDictPendingDrafts,
|
||||||
useRecordRaw,
|
useRecordRaw,
|
||||||
@@ -53,6 +54,7 @@ import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialo
|
|||||||
import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal'
|
import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal'
|
||||||
import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal'
|
import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal'
|
||||||
import { kindBadgeVariant, kindIcon, kindLabel, isVersioningKind } from '@/components/changelog/kind-meta'
|
import { kindBadgeVariant, kindIcon, kindLabel, isVersioningKind } from '@/components/changelog/kind-meta'
|
||||||
|
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
|
||||||
import { nowIsoLocal } from '@/lib/dates'
|
import { nowIsoLocal } from '@/lib/dates'
|
||||||
import { recordDisplayName } from '@/lib/locales'
|
import { recordDisplayName } from '@/lib/locales'
|
||||||
import { SCOPE_BORDER_TOP } from '@/lib/scope-style'
|
import { SCOPE_BORDER_TOP } from '@/lib/scope-style'
|
||||||
@@ -132,6 +134,14 @@ function DictionaryDetail() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const detailQuery = useDictionaryDetail(name)
|
const detailQuery = useDictionaryDetail(name)
|
||||||
const canMutate = useCanMutate()
|
const canMutate = useCanMutate()
|
||||||
|
// Phase 1c: show «Create draft» button only когда approvalRequired=true
|
||||||
|
// и нет active draft'а (иначе banner предложит open existing). Live (без
|
||||||
|
// approval flag) словари редактируются напрямую через EditorDialog —
|
||||||
|
// создавать draft не нужно.
|
||||||
|
const activeSchemaDraftQuery = useDictActiveSchemaDraft(name)
|
||||||
|
const shouldOfferCreateDraft =
|
||||||
|
detailQuery.data?.approvalRequired === true &&
|
||||||
|
!activeSchemaDraftQuery.data
|
||||||
|
|
||||||
// AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт
|
// AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт
|
||||||
// только в local state (не сериализуем в URL — слишком длинно).
|
// только в local state (не сериализуем в URL — слишком длинно).
|
||||||
@@ -194,6 +204,7 @@ function DictionaryDetail() {
|
|||||||
total: 0,
|
total: 0,
|
||||||
})
|
})
|
||||||
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
||||||
|
const [createDraftOpen, setCreateDraftOpen] = useState(false)
|
||||||
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
||||||
const [aoiOpen, setAoiOpen] = useState(false)
|
const [aoiOpen, setAoiOpen] = useState(false)
|
||||||
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
|
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
|
||||||
@@ -703,6 +714,21 @@ function DictionaryDetail() {
|
|||||||
actions={
|
actions={
|
||||||
canMutate ? (
|
canMutate ? (
|
||||||
<>
|
<>
|
||||||
|
{/* approval-required dict без active draft: предлагаем
|
||||||
|
создать черновик. Direct edit через GearIcon оставляем
|
||||||
|
для админов, но primary path — через workflow. */}
|
||||||
|
{shouldOfferCreateDraft && (
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
label={t('createDraft.openButton', {
|
||||||
|
defaultValue: 'Создать черновик схемы',
|
||||||
|
})}
|
||||||
|
variant="default"
|
||||||
|
icon={<GitBranchIcon weight="regular" />}
|
||||||
|
disabled={!detailQuery.data}
|
||||||
|
onClick={() => setCreateDraftOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
label={t('schema.action.editSchema')}
|
label={t('schema.action.editSchema')}
|
||||||
@@ -1214,6 +1240,14 @@ function DictionaryDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{detailQuery.data && (
|
||||||
|
<CreateSchemaDraftModal
|
||||||
|
open={createDraftOpen}
|
||||||
|
onClose={() => setCreateDraftOpen(false)}
|
||||||
|
detail={detailQuery.data}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<RecordHistoryDrawer
|
<RecordHistoryDrawer
|
||||||
open={Boolean(historyKey)}
|
open={Boolean(historyKey)}
|
||||||
onClose={() => setHistoryKey(undefined)}
|
onClose={() => setHistoryKey(undefined)}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
|
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 { useApproveDraft, useRejectDraft } from '@/api/mutations'
|
||||||
import type { DraftOperation, DraftResponse } from '@/api/client'
|
import type { DraftOperation, DraftResponse } from '@/api/client'
|
||||||
|
|
||||||
@@ -142,6 +142,8 @@ function ReviewsPage() {
|
|||||||
description={t('reviews.description')}
|
description={t('reviews.description')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SchemaReviewQueuePanel />
|
||||||
|
|
||||||
{queue.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
{queue.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||||
|
|
||||||
{queue.error && (
|
{queue.error && (
|
||||||
@@ -507,3 +509,80 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
|||||||
</Drawer>
|
</Drawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema review pool queue — global view всех \`review_pending\` schema
|
||||||
|
* draft'ов через {@code GET /admin/schema-reviews/pending}. Reviewer
|
||||||
|
* жмёт «Открыть» → переходит на dict detail page, banner+drawer
|
||||||
|
* автоматически подхватятся через {@code useDictActiveSchemaDraft}.
|
||||||
|
*
|
||||||
|
* <p>Скрывается если 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 (
|
||||||
|
<Panel>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-title-md font-semibold">
|
||||||
|
{t('reviews.schema.title', { defaultValue: 'Изменения схем' })}
|
||||||
|
</h2>
|
||||||
|
<p className="text-cell text-mute mt-0.5">
|
||||||
|
{t('reviews.schema.queueTotal', {
|
||||||
|
count: queue.data?.total ?? items.length,
|
||||||
|
defaultValue: `${queue.data?.total ?? items.length} draft(s) ждут ревью`,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="info">
|
||||||
|
{t('reviews.schema.label', { defaultValue: 'Schema workflow' })}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeaderCell>{t('reviews.schema.dictionary', { defaultValue: 'Словарь' })}</TableHeaderCell>
|
||||||
|
<TableHeaderCell>{t('reviews.schema.fromVersion', { defaultValue: 'От версии' })}</TableHeaderCell>
|
||||||
|
<TableHeaderCell>{t('reviews.schema.maker', { defaultValue: 'Автор' })}</TableHeaderCell>
|
||||||
|
<TableHeaderCell>{t('reviews.schema.submittedAt', { defaultValue: 'Отправлен' })}</TableHeaderCell>
|
||||||
|
<TableHeaderCell align="right">{t('reviews.schema.action', { defaultValue: 'Действие' })}</TableHeaderCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((d) => (
|
||||||
|
<TableRow key={d.draftId}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
to="/dictionaries/$name"
|
||||||
|
params={{ name: d.dictionaryName }}
|
||||||
|
className="text-accent hover:underline font-mono"
|
||||||
|
>
|
||||||
|
{d.dictionaryName}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono tabular-nums">v{d.branchedFromVersion}</TableCell>
|
||||||
|
<TableCell className="font-mono">{d.makerId}</TableCell>
|
||||||
|
<TableCell className="tabular-nums">
|
||||||
|
{d.submittedAt ? new Date(d.submittedAt).toLocaleString() : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Link
|
||||||
|
to="/dictionaries/$name"
|
||||||
|
params={{ name: d.dictionaryName }}
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t('reviews.schema.open', { defaultValue: 'Открыть' })} →
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user