e4cd7b9709
Approach C upgrade на templates foundation (!233). Admin может попросить AI предложить новое поле — system prompt force'ит structural JSON Schema fragment, admin review через accept/edit/reject. Bitemporal draft workflow unchanged downstream. ## Backend - `LlmAdapter` — OpenAI-compatible /v1/chat/completions client (Ollama, vLLM, OpenAI). Bean conditional на ordinis.ai.enabled=true. - `AiSchemaService` — system prompt + 3 few-shot examples, JSON output parsing (с markdown fence stripping), validation (fieldName snake_case). In-memory circuit breaker: 10 fails в 60s → open 5 min. - `AiSchemaController`: - GET /api/v1/ai/info → 200 (enabled) / 404 (disabled). Frontend probe. - POST /api/v1/ai/suggest-field → suggestion. Per-IP rate limit 30/min. - All bean-conditional: ordinis.ai.enabled=false → controllers нет → endpoints 404 → frontend hides AI buttons automatically. ## Frontend - `useAiFeatureAvailable` query — probes /ai/info, cache 5 min - `useAiSuggestField` mutation — 30s timeout (LLM может быть slow) - `AiFieldSuggestPanel` component — inline expandable: prompt → preview → accept/retry/reject. Error handling per HTTP status (503/429/422/502/404). - DictionaryEditorDialog: AI toggle visible на schema tab когда aiAvailable=true. Accept wraps suggestion в mini-schema → parseSchemaJson (reuse existing kind-inference) → push в properties (overwrite если duplicate fieldName). - i18n RU + EN ## Config - application.yml: ordinis.ai.{enabled,endpoint,model,bearer-token, timeout-seconds} - Helm writer.yaml: ORDINIS_AI_* env vars, optional secretRef для bearer - values.yaml: ai.{enabled,endpoint,model,timeoutSeconds,bearerTokenSecretRef} defaults disabled Pair с infra MR — values-staging enables AI с vortex.nstart.cloud Ollama serving qwen2.5:7b.
624 lines
24 KiB
TypeScript
624 lines
24 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { useQueryClient } from '@tanstack/react-query'
|
||
import axios from 'axios'
|
||
import {
|
||
Alert,
|
||
Button,
|
||
FormActions,
|
||
Modal,
|
||
MultiSelect,
|
||
SingleSelect,
|
||
Tabs,
|
||
TextArea,
|
||
TextInput,
|
||
type SelectOption,
|
||
type TabItem,
|
||
} from '@/ui'
|
||
import { useCreateDictionary, useUpdateDictionary } from '@/api/mutations'
|
||
import {
|
||
dictionaryDetailQuery,
|
||
useAiFeatureAvailable,
|
||
useDictionaries,
|
||
} from '@/api/queries'
|
||
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
|
||
import { SchemaBuilder } from './SchemaBuilder'
|
||
import { TemplatePicker } from './TemplatePicker'
|
||
import { AiFieldSuggestPanel } from './AiFieldSuggestPanel'
|
||
import { SparkleIcon } from '@phosphor-icons/react'
|
||
import { EventsPreviewTab } from './EventsPreviewTab'
|
||
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
|
||
import {
|
||
buildSchemaJson,
|
||
diffSchemas,
|
||
hasBreakingChanges,
|
||
parseSchemaJson,
|
||
suggestVersionBump,
|
||
type PropertyDef,
|
||
type SchemaChange,
|
||
} from './types'
|
||
|
||
type Mode =
|
||
| { kind: 'create' }
|
||
| { kind: 'edit'; existing: DictionaryDetail }
|
||
|
||
type Props = {
|
||
open: boolean
|
||
mode: Mode
|
||
onClose: () => void
|
||
onSuccess: (name: string) => void
|
||
}
|
||
|
||
const DEFAULT_LOCALES = ['ru-RU', 'en-US', 'zh-CN']
|
||
|
||
const SCOPE_OPTIONS = (t: (k: string) => string): SelectOption[] => [
|
||
{ id: 'PUBLIC', label: t('scope.PUBLIC') },
|
||
{ id: 'INTERNAL', label: t('scope.INTERNAL') },
|
||
{ id: 'RESTRICTED', label: t('scope.RESTRICTED') },
|
||
]
|
||
|
||
type EditorTab = 'metadata' | 'schema' | 'preview' | 'events'
|
||
|
||
/**
|
||
* Decide whether the editor's Save button should be blocked because the
|
||
* change has to go through the schema-draft workflow instead of inline PUT.
|
||
* Mirrors the three backend gate clauses in DictionaryDefinitionService
|
||
* exactly so the UX matches the server contract.
|
||
*
|
||
* Returns false (don't block) for create-mode, for non-approval-required
|
||
* dicts, and for pure metadata edits (display name / description / locales
|
||
* etc.) that leave schema + approval config alone.
|
||
*
|
||
* Exported for unit testability — the three-clause logic is the load-bearing
|
||
* piece, easier to verify in isolation than mounting the full editor with
|
||
* its query and mutation deps.
|
||
*/
|
||
export function computeRequiresDraftFlow({
|
||
isEdit,
|
||
initialApprovalRequired,
|
||
initialMinRole,
|
||
currentApprovalRequired,
|
||
currentMinRole,
|
||
schemaChangeCount,
|
||
}: {
|
||
isEdit: boolean
|
||
initialApprovalRequired: boolean
|
||
initialMinRole: string | null
|
||
currentApprovalRequired: boolean
|
||
currentMinRole: string | null
|
||
schemaChangeCount: number
|
||
}): boolean {
|
||
if (!isEdit || !initialApprovalRequired) return false
|
||
const schemaChanged = schemaChangeCount > 0
|
||
const approvalToggledOff = !currentApprovalRequired
|
||
const minRoleChanged = initialMinRole !== currentMinRole
|
||
return schemaChanged || approvalToggledOff || minRoleChanged
|
||
}
|
||
|
||
export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props) => {
|
||
const { t } = useTranslation()
|
||
const createMut = useCreateDictionary()
|
||
const updateMut = useUpdateDictionary()
|
||
|
||
const isEdit = mode.kind === 'edit'
|
||
const initial = mode.kind === 'edit' ? mode.existing : null
|
||
|
||
const parsed = useMemo(() => parseSchemaJson(initial?.schemaJson), [initial])
|
||
|
||
const [activeTab, setActiveTab] = useState<EditorTab>('metadata')
|
||
const [name, setName] = useState(initial?.name ?? '')
|
||
const [displayName, setDisplayName] = useState(initial?.displayName ?? '')
|
||
const [description, setDescription] = useState(initial?.description ?? '')
|
||
const [scope, setScope] = useState<DataScope>(initial?.scope ?? 'PUBLIC')
|
||
const [bundle, setBundle] = useState(initial?.bundle ?? 'cuod')
|
||
const [schemaVersion, setSchemaVersion] = useState(initial?.schemaVersion ?? '1.0.0')
|
||
const [supportedLocales, setSupportedLocales] = useState<string[]>(
|
||
initial?.supportedLocales ?? ['ru-RU'],
|
||
)
|
||
const [defaultLocale, setDefaultLocale] = useState(initial?.defaultLocale ?? 'ru-RU')
|
||
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<PropertyDef[]>(parsed.properties)
|
||
const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '')
|
||
const [nameError, setNameError] = useState<string | null>(null)
|
||
/** Open state for the "Создать черновик с моими изменениями" CTA. The CTA
|
||
* is the inline handoff from the disabled-Save path (requiresDraftFlow)
|
||
* into the schema-draft modal with the maker's in-progress schemaJson
|
||
* pre-loaded — no retyping. */
|
||
const [draftHandoffOpen, setDraftHandoffOpen] = useState(false)
|
||
/** AI Schema Assist (Phase 1 step 6): per-field LLM suggest panel toggle. */
|
||
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
||
const aiAvailable = useAiFeatureAvailable()
|
||
|
||
// Template (только в create-mode): admin выбирает существующий dict,
|
||
// его schema + locale + scope копируются. Имя остаётся пустым (admin
|
||
// вводит уникальное), version reset на 1.0.0.
|
||
const queryClient = useQueryClient()
|
||
const dictsQuery = useDictionaries()
|
||
const [loadingTemplate, setLoadingTemplate] = useState(false)
|
||
const templateOptions: SelectOption[] = useMemo(() => {
|
||
if (isEdit) return []
|
||
const list = dictsQuery.data ?? []
|
||
return list
|
||
.map((d) => ({
|
||
id: d.name,
|
||
label: d.displayName ? `${d.name} — ${d.displayName}` : d.name,
|
||
}))
|
||
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
|
||
}, [dictsQuery.data, isEdit])
|
||
|
||
const handleApplyTemplate = async (templateName: string) => {
|
||
if (!templateName) return
|
||
setLoadingTemplate(true)
|
||
try {
|
||
const detail = await queryClient.fetchQuery(dictionaryDetailQuery(templateName))
|
||
const tplParsed = parseSchemaJson(detail.schemaJson)
|
||
// Не трогаем name — admin должен ввести уникальное.
|
||
setDisplayName('')
|
||
setDescription(detail.description ?? '')
|
||
setScope(detail.scope)
|
||
setBundle(detail.bundle ?? 'cuod')
|
||
setSchemaVersion('1.0.0')
|
||
setSupportedLocales(detail.supportedLocales ?? ['ru-RU'])
|
||
setDefaultLocale(detail.defaultLocale ?? 'ru-RU')
|
||
setProperties(tplParsed.properties)
|
||
setIdSource(tplParsed.idSource ?? '')
|
||
} finally {
|
||
setLoadingTemplate(false)
|
||
}
|
||
}
|
||
|
||
const schemaJson = useMemo(
|
||
() => buildSchemaJson(properties, idSource || undefined),
|
||
[properties, idSource],
|
||
)
|
||
|
||
const changes: SchemaChange[] = useMemo(
|
||
() => (isEdit ? diffSchemas(initial?.schemaJson, schemaJson) : []),
|
||
[isEdit, initial, schemaJson],
|
||
)
|
||
const breaking = hasBreakingChanges(changes)
|
||
const suggestedVersion = useMemo(
|
||
() => (isEdit ? suggestVersionBump(initial?.schemaVersion, changes) : schemaVersion),
|
||
[isEdit, initial, changes, schemaVersion],
|
||
)
|
||
|
||
// Approval-aware Save: when editing an approval-required dict, any schema
|
||
// change OR toggling approvalRequired off OR changing approvalMinRole has
|
||
// to route through the schema-draft workflow. The backend will reject with
|
||
// 422 approval_required, but it's much better UX to disable Save up front
|
||
// and tell the user to open a draft instead. Mirror the three backend
|
||
// gate clauses exactly so the UX matches the server contract.
|
||
const requiresDraftFlow = computeRequiresDraftFlow({
|
||
isEdit,
|
||
initialApprovalRequired: Boolean(
|
||
(initial as { approvalRequired?: boolean } | null)?.approvalRequired,
|
||
),
|
||
initialMinRole:
|
||
((initial as { approvalMinRole?: string } | null)?.approvalMinRole ?? '') || null,
|
||
currentApprovalRequired: approvalRequired,
|
||
currentMinRole: approvalMinRole.trim() === '' ? null : approvalMinRole.trim(),
|
||
schemaChangeCount: changes.length,
|
||
})
|
||
|
||
const activeMutation = isEdit ? updateMut : createMut
|
||
const serverError = serverErrorMessage(activeMutation.error)
|
||
|
||
const localeOptions: SelectOption[] = DEFAULT_LOCALES.map((l) => ({ id: l, label: l }))
|
||
const defaultLocaleOptions: SelectOption[] = supportedLocales.map((l) => ({ id: l, label: l }))
|
||
|
||
const handleSubmit = () => {
|
||
if (!isEdit && !/^[a-z][a-z0-9_]{1,127}$/.test(name)) {
|
||
setNameError(t('schema.nameInvalid'))
|
||
setActiveTab('metadata')
|
||
return
|
||
}
|
||
setNameError(null)
|
||
if (isEdit && breaking) {
|
||
setActiveTab('metadata')
|
||
return
|
||
}
|
||
|
||
const payload: CreateDictionaryRequest = {
|
||
name,
|
||
displayName: displayName || undefined,
|
||
description: description || undefined,
|
||
scope,
|
||
schemaJson,
|
||
schemaVersion: schemaVersion || undefined,
|
||
bundle: bundle || undefined,
|
||
supportedLocales: supportedLocales.length > 0 ? supportedLocales : undefined,
|
||
defaultLocale: defaultLocale || undefined,
|
||
redisProjectionEnabled: redisProjection,
|
||
approvalRequired,
|
||
approvalMinRole: approvalMinRole.trim() || undefined,
|
||
}
|
||
|
||
if (isEdit) {
|
||
updateMut.mutate(
|
||
{ name: initial!.name, payload },
|
||
{ onSuccess: () => onSuccess(initial!.name) },
|
||
)
|
||
} else {
|
||
createMut.mutate(payload, { onSuccess: (resp) => onSuccess(resp.name) })
|
||
}
|
||
}
|
||
|
||
const tabs: TabItem[] = [
|
||
{ id: 'metadata', label: t('schema.tabs.metadata') },
|
||
{ id: 'schema', label: t('schema.tabs.schema') },
|
||
{ id: 'preview', label: t('schema.tabs.preview') },
|
||
{ id: 'events', label: t('schema.tabs.events') },
|
||
]
|
||
|
||
return (
|
||
<Modal
|
||
isOpen={open}
|
||
onClose={onClose}
|
||
title={isEdit ? `${t('schema.action.edit')}: ${initial?.name}` : t('schema.action.create')}
|
||
maxWidth="max-w-5xl"
|
||
panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col"
|
||
bodyClassName="overflow-y-auto flex-1"
|
||
>
|
||
<div className="space-y-4">
|
||
<Tabs items={tabs} value={activeTab} onValueChange={(id) => setActiveTab(id as EditorTab)} />
|
||
|
||
<div className={activeTab === 'metadata' ? 'block' : 'hidden'}>
|
||
{!isEdit && (
|
||
<div className="mb-3 p-3 rounded-md border border-accent/20 bg-accent/4">
|
||
<SingleSelect
|
||
label={t('schema.template.label')}
|
||
hint={t('schema.template.hint')}
|
||
options={templateOptions}
|
||
value=""
|
||
onChange={(id) => {
|
||
void handleApplyTemplate(id)
|
||
}}
|
||
placeholder={
|
||
dictsQuery.isLoading || loadingTemplate
|
||
? '…'
|
||
: t('schema.template.placeholder')
|
||
}
|
||
disabled={dictsQuery.isLoading || loadingTemplate}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<TextInput
|
||
label={t('schema.name')}
|
||
required
|
||
disabled={isEdit}
|
||
placeholder="my_dictionary"
|
||
hint={t('schema.nameHint')}
|
||
error={nameError ?? undefined}
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
/>
|
||
<TextInput
|
||
label={t('schema.displayName')}
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
/>
|
||
<div className="sm:col-span-2">
|
||
<TextArea
|
||
label={t('schema.description')}
|
||
rows={2}
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
/>
|
||
</div>
|
||
<SingleSelect
|
||
label={t('schema.scope')}
|
||
required
|
||
options={SCOPE_OPTIONS(t)}
|
||
value={scope}
|
||
onChange={(id) => setScope(id as DataScope)}
|
||
/>
|
||
<TextInput
|
||
label={t('schema.bundle')}
|
||
hint={t('schema.bundleHint')}
|
||
value={bundle}
|
||
onChange={(e) => setBundle(e.target.value)}
|
||
/>
|
||
<TextInput
|
||
label={t('schema.version')}
|
||
placeholder="1.0.0"
|
||
hint={
|
||
isEdit && suggestedVersion !== schemaVersion
|
||
? t('schema.versionSuggest', { v: suggestedVersion })
|
||
: undefined
|
||
}
|
||
value={schemaVersion}
|
||
onChange={(e) => setSchemaVersion(e.target.value)}
|
||
/>
|
||
<SingleSelect
|
||
label={t('schema.idSource')}
|
||
hint={t('schema.idSourceHint')}
|
||
options={[
|
||
{ id: '', label: '— ' + t('schema.idSourceManual') + ' —' },
|
||
...properties
|
||
.filter((p) => p.name && (p.kind === 'string' || p.kind === 'integer' || p.kind === 'number'))
|
||
.map((p) => ({ id: p.name, label: p.name })),
|
||
]}
|
||
value={idSource}
|
||
onChange={setIdSource}
|
||
/>
|
||
<MultiSelect
|
||
label={t('schema.supportedLocales')}
|
||
options={localeOptions}
|
||
value={supportedLocales}
|
||
onChange={(ids) => {
|
||
setSupportedLocales(ids)
|
||
if (!ids.includes(defaultLocale) && ids.length > 0) {
|
||
setDefaultLocale(ids[0])
|
||
}
|
||
}}
|
||
/>
|
||
<SingleSelect
|
||
label={t('schema.defaultLocale')}
|
||
options={defaultLocaleOptions}
|
||
value={defaultLocale}
|
||
onChange={setDefaultLocale}
|
||
/>
|
||
{/* CEO plan E2: per-dict Redis projection opt-in. Default false.
|
||
Включается когда dict хочет 10k+ RPS на read-api (вместо PG). */}
|
||
<label className="md:col-span-2 flex items-start gap-3 px-3 py-2 rounded-sm border border-line bg-line/20 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={redisProjection}
|
||
onChange={(e) => setRedisProjection(e.target.checked)}
|
||
className="mt-0.5 size-4 accent-ultramarain"
|
||
/>
|
||
<span className="flex-1">
|
||
<span className="block text-body font-sans text-ink">
|
||
{t('schema.redisProjection.label')}
|
||
</span>
|
||
<span className="block text-cell text-mute mt-0.5">
|
||
{t('schema.redisProjection.hint')}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
|
||
{/* Approval Workflow v2: per-dict opt-in. Default false. Включается
|
||
для критичных dicts (ground_station, spacecraft и т.д.) где
|
||
один admin не должен иметь power публиковать без review. */}
|
||
<label className="md:col-span-2 flex items-start gap-3 px-3 py-2 rounded-sm border border-warn bg-warn-bg cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={approvalRequired}
|
||
onChange={(e) => setApprovalRequired(e.target.checked)}
|
||
className="mt-0.5 size-4 accent-orbit"
|
||
/>
|
||
<span className="flex-1">
|
||
<span className="block text-body font-sans text-ink">
|
||
{t('schema.approvalRequired.label')}
|
||
</span>
|
||
<span className="block text-cell text-mute mt-0.5">
|
||
{t('schema.approvalRequired.hint')}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
|
||
{approvalRequired && (
|
||
<div className="md:col-span-2">
|
||
<label
|
||
htmlFor="approval-min-role"
|
||
className="text-cap text-ink-2"
|
||
>
|
||
{t('schema.approvalMinRole.label')}
|
||
</label>
|
||
<TextInput
|
||
id="approval-min-role"
|
||
value={approvalMinRole}
|
||
onChange={(e) => setApprovalMinRole(e.target.value)}
|
||
placeholder="ordinis:internal"
|
||
/>
|
||
<p className="text-cell text-mute mt-0.5">
|
||
{t('schema.approvalMinRole.hint')}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={activeTab === 'schema' ? 'block' : 'hidden'}>
|
||
{/* AI Schema Assist Phase 1: template picker visible только в create-mode
|
||
* И только пока пользователь не начал заполнять properties (чтобы не
|
||
* перезатереть его работу одним кликом). После первой property —
|
||
* picker исчезает (можно reset через template via existing dict templ
|
||
* UI выше или вручную clear). */}
|
||
{!isEdit && properties.length === 0 && (
|
||
<div className="mb-4">
|
||
<TemplatePicker
|
||
disabled={loadingTemplate || createMut.isPending}
|
||
onPick={(template) => {
|
||
const tplParsed = parseSchemaJson(template.schemaJson)
|
||
setProperties(tplParsed.properties)
|
||
setIdSource(tplParsed.idSource ?? '')
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
{/* AI Schema Assist Phase 1 step 6: per-field LLM suggest panel.
|
||
* Toggle hidden когда ordinis.ai.enabled=false на бэкенде (probe
|
||
* via /api/v1/ai/info → 404 = hide). */}
|
||
{aiAvailable.data && (
|
||
<div className="mb-4">
|
||
{!aiPanelOpen ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setAiPanelOpen(true)}
|
||
className="inline-flex items-center gap-1.5 text-cell text-accent hover:underline"
|
||
>
|
||
<SparkleIcon size={14} weight="fill" />
|
||
{t('aiSuggest.openButton', { defaultValue: 'AI: добавить поле' })}
|
||
</button>
|
||
) : (
|
||
<AiFieldSuggestPanel
|
||
existingSchema={schemaJson as Record<string, unknown>}
|
||
onClose={() => setAiPanelOpen(false)}
|
||
onAccept={(suggestion) => {
|
||
// Wrap suggestion в mini-schema → parse через existing
|
||
// parseSchemaJson → получаем PropertyDef. Reuse уже
|
||
// tested kind-inference logic.
|
||
const wrapper = {
|
||
type: 'object',
|
||
properties: { [suggestion.fieldName]: suggestion.schema },
|
||
}
|
||
const parsed = parseSchemaJson(wrapper as never)
|
||
if (parsed.properties.length > 0) {
|
||
// Avoid duplicate field name — overwrite если уже есть.
|
||
const existingIdx = properties.findIndex(
|
||
(p) => p.name === suggestion.fieldName,
|
||
)
|
||
if (existingIdx >= 0) {
|
||
const next = [...properties]
|
||
next[existingIdx] = parsed.properties[0]
|
||
setProperties(next)
|
||
} else {
|
||
setProperties([...properties, parsed.properties[0]])
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
<SchemaBuilder properties={properties} onChange={setProperties} />
|
||
</div>
|
||
|
||
<div className={activeTab === 'preview' ? 'block' : 'hidden'}>
|
||
<pre className="text-mono bg-line/30 rounded p-3 overflow-x-auto whitespace-pre-wrap">
|
||
{JSON.stringify(schemaJson, null, 2)}
|
||
</pre>
|
||
</div>
|
||
|
||
<div className={activeTab === 'events' ? 'block' : 'hidden'}>
|
||
{/* Phase 4.5 / CEO plan v1: integrators видят shape Kafka events
|
||
до published'а dictionary. Sample data на основе schema fields. */}
|
||
<EventsPreviewTab
|
||
dictionaryName={name}
|
||
dataScope={scope}
|
||
bundle={bundle}
|
||
schema={schemaJson}
|
||
businessKeyField={idSource || undefined}
|
||
/>
|
||
</div>
|
||
|
||
{isEdit && breaking && (
|
||
<Alert variant="error" title={t('schema.diff.blockedTitle')}>
|
||
<div className="space-y-2">
|
||
<p className="text-body">{t('schema.diff.blockedBody')}</p>
|
||
<ul className="list-disc pl-5 text-body space-y-0.5">
|
||
{changes
|
||
.filter((c) => c.severity === 'breaking')
|
||
.map((c, i) => (
|
||
<li key={i}>
|
||
<span className="font-mono">{c.field}</span> — {c.reason}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</Alert>
|
||
)}
|
||
|
||
{isEdit && !breaking && changes.length > 0 && (
|
||
<Alert variant="info" title={t('schema.diff.compatibleTitle')}>
|
||
<ul className="list-disc pl-5 text-body space-y-0.5">
|
||
{changes.map((c, i) => (
|
||
<li key={i}>
|
||
<span className="font-mono">{c.field}</span> — {c.reason}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</Alert>
|
||
)}
|
||
|
||
{serverError && <Alert variant="error">{serverError}</Alert>}
|
||
|
||
{requiresDraftFlow && (
|
||
<Alert
|
||
variant="warning"
|
||
title={t('schema.approvalRequired.editorBlockTitle', {
|
||
defaultValue: 'Изменения требуют ревью',
|
||
})}
|
||
action={
|
||
<Button
|
||
type="button"
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={() => setDraftHandoffOpen(true)}
|
||
>
|
||
{t('schema.approvalRequired.openDraftCta', {
|
||
defaultValue: 'Создать черновик с моими изменениями',
|
||
})}
|
||
</Button>
|
||
}
|
||
>
|
||
{t('schema.approvalRequired.editorBlock', {
|
||
defaultValue:
|
||
'Этот словарь требует maker-checker review. Откройте черновик — ' +
|
||
'ваши текущие изменения будут предзаполнены, вам нужно только ' +
|
||
'указать причину и отправить на согласование.',
|
||
})}
|
||
</Alert>
|
||
)}
|
||
|
||
<FormActions align="end">
|
||
<Button type="button" variant="ghost" onClick={onClose} disabled={activeMutation.isPending}>
|
||
{t('form.cancel')}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="primary"
|
||
loading={activeMutation.isPending}
|
||
disabled={(isEdit && breaking) || requiresDraftFlow}
|
||
onClick={handleSubmit}
|
||
>
|
||
{activeMutation.isPending ? t('form.saving') : t('form.save')}
|
||
</Button>
|
||
</FormActions>
|
||
</div>
|
||
{/* Inline handoff: CreateSchemaDraftModal mounted with the in-progress
|
||
schemaJson pre-loaded. Maker hits "Создать черновик с моими изменениями"
|
||
→ modal opens with the same edits they had → they add a reason and
|
||
submit. No retyping, no hunt for a button on the dict page. */}
|
||
{isEdit && initial && (
|
||
<CreateSchemaDraftModal
|
||
open={draftHandoffOpen}
|
||
onClose={() => setDraftHandoffOpen(false)}
|
||
detail={initial}
|
||
initialProposedSchema={schemaJson}
|
||
onCreated={() => {
|
||
// Cascade close: dismiss the draft modal AND the editor. Once
|
||
// the draft is submitted, the in-memory edit in the editor is
|
||
// stale by design — the draft is now the source of truth until
|
||
// a reviewer decides. Leaving the editor open would let the
|
||
// maker silently mutate further state that can't go anywhere
|
||
// (Save still disabled until the draft resolves).
|
||
setDraftHandoffOpen(false)
|
||
onClose()
|
||
}}
|
||
/>
|
||
)}
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
const serverErrorMessage = (err: unknown): string | null => {
|
||
if (!err) return null
|
||
if (axios.isAxiosError(err)) {
|
||
const data = err.response?.data as { message?: string; error?: string } | undefined
|
||
return data?.message ?? data?.error ?? err.message
|
||
}
|
||
return err instanceof Error ? err.message : 'Unknown error'
|
||
}
|