feat(schema-workflow): Phase 1b — SchemaDraftDrawer with status-aware actions
This commit is contained in:
@@ -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<SchemaDraftStatus> = 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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<SchemaDraft> => {
|
||||
const { data } = await apiClient.post<SchemaDraft>(
|
||||
`/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<SchemaDraft> => {
|
||||
const { data } = await apiClient.post<SchemaDraft>(
|
||||
`/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<SchemaDraft> => {
|
||||
const { data } = await apiClient.post<SchemaDraft>(
|
||||
`/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}.
|
||||
*
|
||||
* <p>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<PublishSchemaDraftResult> => {
|
||||
const { data } = await apiClient.post<PublishSchemaDraftResult>(
|
||||
`/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<SchemaDraft> => {
|
||||
const { data } = await apiClient.delete<SchemaDraft>(
|
||||
`/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({
|
||||
|
||||
@@ -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'а.
|
||||
*
|
||||
* <p>refetchInterval=20s — banner должен быстро отражать «отправили
|
||||
* на ревью» / «approved» transition от другого пользователя.
|
||||
*/
|
||||
export const dictActiveSchemaDraftQuery = (dictName: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['schema-draft-active', dictName] as const,
|
||||
queryFn: async (): Promise<SchemaDraft | null> => {
|
||||
try {
|
||||
const { data } = await apiClient.get<SchemaDraft>(
|
||||
`/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<SchemaDraft[]> => {
|
||||
const { data } = await apiClient.get<SchemaDraft[]>(
|
||||
`/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<SchemaDraft> => {
|
||||
const { data } = await apiClient.get<SchemaDraft>(
|
||||
`/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<SchemaReviewQueuePage> => {
|
||||
const { data } = await apiClient.get<SchemaReviewQueuePage>(
|
||||
'/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,
|
||||
|
||||
@@ -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 <SchemaDraftBanner draft={schemaDraft} detail={detail} />
|
||||
}
|
||||
|
||||
// === 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<BannerProps['variant'], string> = {
|
||||
draft: 'bg-warn-bg border-l-warn text-ink',
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema-level draft banner — surface'им active non-terminal draft каждому
|
||||
* пользователю который заходит в editor словаря. Tone зависит от status'а:
|
||||
*
|
||||
* <ul>
|
||||
* <li>draft / changes_requested — warn (maker должен дойти и submit'нуть).</li>
|
||||
* <li>review_pending — info (approvers watching, maker waits).</li>
|
||||
* <li>approved — review (готов к publish, кто-то с publish_role нажмёт).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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<SchemaDraftStatus, string> = {
|
||||
draft: 'Черновик схемы создан',
|
||||
review_pending: 'Черновик схемы на ревью',
|
||||
approved: 'Черновик схемы одобрен',
|
||||
changes_requested: 'По черновику запрошены правки',
|
||||
rejected: 'Черновик схемы отклонён',
|
||||
published: 'Черновик опубликован',
|
||||
withdrawn: 'Черновик отозван',
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Banner
|
||||
variant={variant}
|
||||
icon={<GitBranch size={16} strokeWidth={2} className="shrink-0" />}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="text-body font-medium text-accent hover:underline shrink-0 cursor-pointer"
|
||||
>
|
||||
{t('workflow.schemaDraft.open', { defaultValue: 'Открыть черновик' })} →
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t(labelKey, { defaultValue: fallbackLabel[draft.status] })}
|
||||
</span>
|
||||
<span className="text-cell opacity-70">
|
||||
{t('workflow.schemaDraft.subtitle', {
|
||||
fromVersion: draft.branchedFromVersion,
|
||||
defaultValue: `от v${draft.branchedFromVersion}`,
|
||||
})}
|
||||
</span>
|
||||
</Banner>
|
||||
<SchemaDraftDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => 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 (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import axios from 'axios'
|
||||
import { Alert, Badge, Button, Drawer, LoadingBlock, TextArea, toast } from '@/ui'
|
||||
import {
|
||||
CheckIcon,
|
||||
PaperPlaneTiltIcon,
|
||||
XCircleIcon,
|
||||
ArrowsCounterClockwiseIcon,
|
||||
RocketIcon,
|
||||
TrashIcon,
|
||||
} from '@phosphor-icons/react'
|
||||
import {
|
||||
isActiveSchemaDraft,
|
||||
type SchemaDraft,
|
||||
type SchemaDraftStatus,
|
||||
type SchemaDraftVerdict,
|
||||
} from '@/api/client'
|
||||
import { useDictActiveSchemaDraft } from '@/api/queries'
|
||||
import {
|
||||
useDecideSchemaDraft,
|
||||
usePublishSchemaDraft,
|
||||
useSubmitSchemaDraftForReview,
|
||||
useWithdrawSchemaDraft,
|
||||
} from '@/api/mutations'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
dictionaryName: string
|
||||
/** Current live schemaVersion — passed для publish expectedHeadVersion check. */
|
||||
currentHeadVersion: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-side drawer для schema draft — full state + status-aware actions.
|
||||
*
|
||||
* <p>Загружает активный draft через {@link useDictActiveSchemaDraft}.
|
||||
* Comment textarea используется как:
|
||||
* <ul>
|
||||
* <li>{@code review_pending} — reviewer пишет decision comment (approve/
|
||||
* request_changes/reject).</li>
|
||||
* <li>{@code draft / changes_requested} — maker note при submit.</li>
|
||||
* <li>{@code approved} — publish release note.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Все действия идут через mutations из {@code @/api/mutations}.
|
||||
* Backend сам отрезает невалидные transitions (403 self_approve_forbidden,
|
||||
* 409 invalid_transition, 409 version_mismatch) — мы показываем error
|
||||
* toast с raw message.
|
||||
*
|
||||
* <p>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 (
|
||||
<Drawer
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
side="right"
|
||||
widthClassName="w-full max-w-xl"
|
||||
title={t('workflow.schemaDraft.drawerTitle', {
|
||||
defaultValue: 'Черновик схемы',
|
||||
})}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||
{error && (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(error)}
|
||||
</Alert>
|
||||
)}
|
||||
{!isLoading && !draft && (
|
||||
<p className="text-body text-mute">
|
||||
{t('workflow.schemaDraft.empty', {
|
||||
defaultValue: 'Активного черновика схемы нет.',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{draft && (
|
||||
<>
|
||||
<DraftHeader draft={draft} />
|
||||
<DraftMeta draft={draft} />
|
||||
|
||||
{/* Read-only proposed schema. Edit flow (CreateModal) — отдельный
|
||||
cliff в Phase 1c. Здесь просто видим что предлагается. */}
|
||||
<ProposedSchemaPane data={draft.proposedSchema} />
|
||||
|
||||
{/* Comment textarea. Один текстовое поле, semantics зависит от
|
||||
status'а (см. drawer-level JSDoc). */}
|
||||
<div>
|
||||
<label className="block text-cap text-mute font-display uppercase tracking-wider mb-1">
|
||||
{commentLabel(draft.status, t)}
|
||||
</label>
|
||||
<TextArea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder={commentPlaceholder(draft.status, t)}
|
||||
rows={3}
|
||||
disabled={anyPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action bar — status-aware. Bottom-sticky визуально, но рендерим
|
||||
inline т.к. Drawer sheet flow-based. */}
|
||||
<DraftActions
|
||||
status={draft.status}
|
||||
onSubmitReview={onSubmitReview}
|
||||
onDecide={onDecide}
|
||||
onPublish={onPublish}
|
||||
onWithdraw={onWithdraw}
|
||||
disabled={anyPending}
|
||||
hasCurrentVersion={Boolean(currentHeadVersion)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftHeader({ draft }: { draft: SchemaDraft }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<span className="text-mono text-cell text-mute">
|
||||
{draft.draftId.slice(0, 8)}
|
||||
</span>
|
||||
<StatusBadge status={draft.status} />
|
||||
{!isActiveSchemaDraft(draft.status) && (
|
||||
<Badge variant="outline">
|
||||
{t('workflow.schemaDraft.terminal', { defaultValue: 'terminal' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: SchemaDraftStatus }) {
|
||||
const { t } = useTranslation()
|
||||
const variant: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'primary' = (() => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
case 'changes_requested':
|
||||
return 'warning'
|
||||
case 'review_pending':
|
||||
return 'info'
|
||||
case 'approved':
|
||||
return 'success'
|
||||
case 'rejected':
|
||||
case 'withdrawn':
|
||||
return 'danger'
|
||||
case 'published':
|
||||
return 'primary'
|
||||
}
|
||||
})()
|
||||
const fallback: Record<SchemaDraftStatus, string> = {
|
||||
draft: 'Черновик',
|
||||
review_pending: 'На ревью',
|
||||
approved: 'Одобрено',
|
||||
changes_requested: 'Запрошены правки',
|
||||
rejected: 'Отклонён',
|
||||
published: 'Опубликован',
|
||||
withdrawn: 'Отозван',
|
||||
}
|
||||
return (
|
||||
<Badge variant={variant}>
|
||||
{t(`workflow.schemaDraft.statusBadge.${status}`, {
|
||||
defaultValue: fallback[status],
|
||||
})}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftMeta({ draft }: { draft: SchemaDraft }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<dl className="grid grid-cols-[120px_1fr] gap-y-1 gap-x-3 text-cell">
|
||||
<Row label={t('workflow.schemaDraft.maker', { defaultValue: 'Автор' })}>
|
||||
<span className="font-mono">{draft.makerId}</span>
|
||||
</Row>
|
||||
<Row label={t('workflow.schemaDraft.from', { defaultValue: 'От версии' })}>
|
||||
v{draft.branchedFromVersion}
|
||||
</Row>
|
||||
<Row label={t('workflow.schemaDraft.createdAt', { defaultValue: 'Создан' })}>
|
||||
<span className="tabular-nums">
|
||||
{new Date(draft.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</Row>
|
||||
{draft.submittedAt && (
|
||||
<Row label={t('workflow.schemaDraft.submittedAt', { defaultValue: 'На ревью с' })}>
|
||||
<span className="tabular-nums">
|
||||
{new Date(draft.submittedAt).toLocaleString()}
|
||||
</span>
|
||||
</Row>
|
||||
)}
|
||||
{draft.decisionAt && (
|
||||
<Row label={t('workflow.schemaDraft.decisionAt', { defaultValue: 'Решение' })}>
|
||||
<span className="tabular-nums">
|
||||
{new Date(draft.decisionAt).toLocaleString()}
|
||||
</span>
|
||||
</Row>
|
||||
)}
|
||||
{draft.reason && (
|
||||
<Row label={t('workflow.schemaDraft.reason', { defaultValue: 'Причина' })}>
|
||||
<span className="italic">{draft.reason}</span>
|
||||
</Row>
|
||||
)}
|
||||
{draft.decisionComment && (
|
||||
<Row label={t('workflow.schemaDraft.decisionComment', {
|
||||
defaultValue: 'Комментарий',
|
||||
})}>
|
||||
<span className="italic">{draft.decisionComment}</span>
|
||||
</Row>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-mute font-display uppercase tracking-wider text-cap">{label}</dt>
|
||||
<dd className="text-ink">{children}</dd>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProposedSchemaPane({ data }: { data: unknown }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-surface overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-line text-cap font-semibold uppercase tracking-wider text-mute">
|
||||
{t('workflow.schemaDraft.proposed', {
|
||||
defaultValue: 'Предложенная схема',
|
||||
})}
|
||||
</div>
|
||||
<pre className="text-mono text-cell p-3 overflow-auto max-h-[40vh] bg-[#0c0a23] text-[#f0eaff]">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftActions({
|
||||
status,
|
||||
onSubmitReview,
|
||||
onDecide,
|
||||
onPublish,
|
||||
onWithdraw,
|
||||
disabled,
|
||||
hasCurrentVersion,
|
||||
}: {
|
||||
status: SchemaDraftStatus
|
||||
onSubmitReview: () => void
|
||||
onDecide: (v: SchemaDraftVerdict) => void
|
||||
onPublish: () => void
|
||||
onWithdraw: () => void
|
||||
disabled: boolean
|
||||
hasCurrentVersion: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!isActiveSchemaDraft(status)) {
|
||||
return (
|
||||
<p className="text-cell text-mute italic">
|
||||
{t('workflow.schemaDraft.terminalHint', {
|
||||
defaultValue: 'Терминальный статус — действия недоступны.',
|
||||
})}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-line">
|
||||
{(status === 'draft' || status === 'changes_requested') && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onSubmitReview}
|
||||
disabled={disabled}
|
||||
leftIcon={<PaperPlaneTiltIcon weight="regular" size={14} />}
|
||||
>
|
||||
{status === 'draft'
|
||||
? t('workflow.schemaDraft.actions.submit', {
|
||||
defaultValue: 'Отправить на ревью',
|
||||
})
|
||||
: t('workflow.schemaDraft.actions.resubmit', {
|
||||
defaultValue: 'Доработать и отправить',
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{status === 'review_pending' && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onDecide('APPROVE')}
|
||||
disabled={disabled}
|
||||
leftIcon={<CheckIcon weight="regular" size={14} />}
|
||||
>
|
||||
{t('workflow.schemaDraft.actions.approve', { defaultValue: 'Approve' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onDecide('REQUEST_CHANGES')}
|
||||
disabled={disabled}
|
||||
leftIcon={<ArrowsCounterClockwiseIcon weight="regular" size={14} />}
|
||||
>
|
||||
{t('workflow.schemaDraft.actions.requestChanges', {
|
||||
defaultValue: 'Запросить правки',
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDecide('REJECT')}
|
||||
disabled={disabled}
|
||||
leftIcon={<XCircleIcon weight="regular" size={14} />}
|
||||
>
|
||||
{t('workflow.schemaDraft.actions.reject', { defaultValue: 'Отклонить' })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status === 'approved' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onPublish}
|
||||
disabled={disabled || !hasCurrentVersion}
|
||||
leftIcon={<RocketIcon weight="regular" size={14} />}
|
||||
>
|
||||
{t('workflow.schemaDraft.actions.publish', { defaultValue: 'Опубликовать' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onWithdraw}
|
||||
disabled={disabled}
|
||||
className="ml-auto text-danger hover:text-danger"
|
||||
leftIcon={<TrashIcon weight="regular" size={14} />}
|
||||
>
|
||||
{t('workflow.schemaDraft.actions.withdraw', { defaultValue: 'Отозвать' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function commentLabel(status: SchemaDraftStatus, t: (k: string, o?: { defaultValue?: string }) => string): string {
|
||||
switch (status) {
|
||||
case 'review_pending':
|
||||
return t('workflow.schemaDraft.comment.decision', {
|
||||
defaultValue: 'Комментарий ревьюера',
|
||||
})
|
||||
case 'approved':
|
||||
return t('workflow.schemaDraft.comment.publish', {
|
||||
defaultValue: 'Release note',
|
||||
})
|
||||
case 'draft':
|
||||
case 'changes_requested':
|
||||
return t('workflow.schemaDraft.comment.note', {
|
||||
defaultValue: 'Комментарий автора',
|
||||
})
|
||||
default:
|
||||
return t('workflow.schemaDraft.comment.note', { defaultValue: 'Комментарий' })
|
||||
}
|
||||
}
|
||||
|
||||
function commentPlaceholder(status: SchemaDraftStatus, t: (k: string, o?: { defaultValue?: string }) => string): string {
|
||||
switch (status) {
|
||||
case 'review_pending':
|
||||
return t('workflow.schemaDraft.comment.decisionPlaceholder', {
|
||||
defaultValue: 'Краткое обоснование решения (опционально)',
|
||||
})
|
||||
case 'approved':
|
||||
return t('workflow.schemaDraft.comment.publishPlaceholder', {
|
||||
defaultValue: 'Что меняется в этой версии (попадёт в changelog)',
|
||||
})
|
||||
default:
|
||||
return t('workflow.schemaDraft.comment.notePlaceholder', {
|
||||
defaultValue: 'На что обратить внимание ревьюеру',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function verdictSuccessFallback(v: SchemaDraftVerdict): string {
|
||||
switch (v) {
|
||||
case 'APPROVE':
|
||||
return 'Черновик одобрен'
|
||||
case 'REQUEST_CHANGES':
|
||||
return 'Запрошены правки'
|
||||
case 'REJECT':
|
||||
return 'Черновик отклонён'
|
||||
}
|
||||
}
|
||||
@@ -423,6 +423,59 @@ i18n
|
||||
'changelog.kind.draft_changes_requested': 'Запрошены правки',
|
||||
'changelog.kind.draft_publish': 'Опубликован',
|
||||
'changelog.kind.draft_withdraw': 'Отозван',
|
||||
// === Workflow banner: schema-draft state ===
|
||||
'workflow.schemaDraft.draft': 'Черновик схемы создан',
|
||||
'workflow.schemaDraft.review_pending': 'Черновик схемы на ревью',
|
||||
'workflow.schemaDraft.approved': 'Черновик схемы одобрен — готов к публикации',
|
||||
'workflow.schemaDraft.changes_requested': 'По черновику запрошены правки',
|
||||
'workflow.schemaDraft.rejected': 'Черновик схемы отклонён',
|
||||
'workflow.schemaDraft.published': 'Черновик опубликован',
|
||||
'workflow.schemaDraft.withdrawn': 'Черновик отозван',
|
||||
'workflow.schemaDraft.subtitle': 'от v{{fromVersion}}',
|
||||
'workflow.schemaDraft.open': 'Открыть черновик',
|
||||
'workflow.schemaDraft.drawerTitle': 'Черновик схемы',
|
||||
'workflow.schemaDraft.empty': 'Активного черновика схемы нет.',
|
||||
'workflow.schemaDraft.terminal': 'terminal',
|
||||
'workflow.schemaDraft.terminalHint': 'Терминальный статус — действия недоступны.',
|
||||
'workflow.schemaDraft.maker': 'Автор',
|
||||
'workflow.schemaDraft.from': 'От версии',
|
||||
'workflow.schemaDraft.createdAt': 'Создан',
|
||||
'workflow.schemaDraft.submittedAt': 'На ревью с',
|
||||
'workflow.schemaDraft.decisionAt': 'Решение',
|
||||
'workflow.schemaDraft.reason': 'Причина',
|
||||
'workflow.schemaDraft.decisionComment': 'Комментарий',
|
||||
'workflow.schemaDraft.proposed': 'Предложенная схема',
|
||||
'workflow.schemaDraft.confirmWithdraw': 'Отозвать черновик? Действие нельзя отменить.',
|
||||
'workflow.schemaDraft.statusBadge.draft': 'Черновик',
|
||||
'workflow.schemaDraft.statusBadge.review_pending': 'На ревью',
|
||||
'workflow.schemaDraft.statusBadge.approved': 'Одобрено',
|
||||
'workflow.schemaDraft.statusBadge.changes_requested': 'Запрошены правки',
|
||||
'workflow.schemaDraft.statusBadge.rejected': 'Отклонён',
|
||||
'workflow.schemaDraft.statusBadge.published': 'Опубликован',
|
||||
'workflow.schemaDraft.statusBadge.withdrawn': 'Отозван',
|
||||
'workflow.schemaDraft.comment.decision': 'Комментарий ревьюера',
|
||||
'workflow.schemaDraft.comment.publish': 'Release note',
|
||||
'workflow.schemaDraft.comment.note': 'Комментарий автора',
|
||||
'workflow.schemaDraft.comment.decisionPlaceholder': 'Краткое обоснование решения (опционально)',
|
||||
'workflow.schemaDraft.comment.publishPlaceholder': 'Что меняется в этой версии (попадёт в changelog)',
|
||||
'workflow.schemaDraft.comment.notePlaceholder': 'На что обратить внимание ревьюеру',
|
||||
'workflow.schemaDraft.actions.submit': 'Отправить на ревью',
|
||||
'workflow.schemaDraft.actions.resubmit': 'Доработать и отправить',
|
||||
'workflow.schemaDraft.actions.approve': 'Approve',
|
||||
'workflow.schemaDraft.actions.requestChanges': 'Запросить правки',
|
||||
'workflow.schemaDraft.actions.reject': 'Отклонить',
|
||||
'workflow.schemaDraft.actions.publish': 'Опубликовать',
|
||||
'workflow.schemaDraft.actions.withdraw': 'Отозвать',
|
||||
'workflow.schemaDraft.toast.submitted': 'Отправлено на ревью',
|
||||
'workflow.schemaDraft.toast.approve': 'Черновик одобрен',
|
||||
'workflow.schemaDraft.toast.request_changes': 'Запрошены правки',
|
||||
'workflow.schemaDraft.toast.reject': 'Черновик отклонён',
|
||||
'workflow.schemaDraft.toast.published': 'Опубликовано v{{version}}',
|
||||
'workflow.schemaDraft.toast.withdrawn': 'Черновик отозван',
|
||||
'workflow.schemaDraft.toast.submitFailed': 'Не удалось отправить на ревью',
|
||||
'workflow.schemaDraft.toast.decideFailed': 'Не удалось зафиксировать решение',
|
||||
'workflow.schemaDraft.toast.publishFailed': 'Не удалось опубликовать',
|
||||
'workflow.schemaDraft.toast.withdrawFailed': 'Не удалось отозвать',
|
||||
// === Lineage (dict-relationships-v2 Phase 1) ===
|
||||
'lineage.usedBy.title': 'На этот словарь ссылаются',
|
||||
'lineage.usedBy.count_one': '{{count}} связь',
|
||||
@@ -962,6 +1015,59 @@ i18n
|
||||
'changelog.kind.draft_changes_requested': 'Changes requested',
|
||||
'changelog.kind.draft_publish': 'Published',
|
||||
'changelog.kind.draft_withdraw': 'Withdrawn',
|
||||
// === Workflow banner: schema-draft state ===
|
||||
'workflow.schemaDraft.draft': 'Schema draft created',
|
||||
'workflow.schemaDraft.review_pending': 'Schema draft under review',
|
||||
'workflow.schemaDraft.approved': 'Schema draft approved — ready to publish',
|
||||
'workflow.schemaDraft.changes_requested': 'Changes requested on draft',
|
||||
'workflow.schemaDraft.rejected': 'Schema draft rejected',
|
||||
'workflow.schemaDraft.published': 'Schema draft published',
|
||||
'workflow.schemaDraft.withdrawn': 'Schema draft withdrawn',
|
||||
'workflow.schemaDraft.subtitle': 'from v{{fromVersion}}',
|
||||
'workflow.schemaDraft.open': 'Open draft',
|
||||
'workflow.schemaDraft.drawerTitle': 'Schema draft',
|
||||
'workflow.schemaDraft.empty': 'No active schema draft.',
|
||||
'workflow.schemaDraft.terminal': 'terminal',
|
||||
'workflow.schemaDraft.terminalHint': 'Terminal status — no actions available.',
|
||||
'workflow.schemaDraft.maker': 'Author',
|
||||
'workflow.schemaDraft.from': 'From version',
|
||||
'workflow.schemaDraft.createdAt': 'Created',
|
||||
'workflow.schemaDraft.submittedAt': 'Submitted',
|
||||
'workflow.schemaDraft.decisionAt': 'Decided',
|
||||
'workflow.schemaDraft.reason': 'Reason',
|
||||
'workflow.schemaDraft.decisionComment': 'Comment',
|
||||
'workflow.schemaDraft.proposed': 'Proposed schema',
|
||||
'workflow.schemaDraft.confirmWithdraw': 'Withdraw draft? This action is irreversible.',
|
||||
'workflow.schemaDraft.statusBadge.draft': 'Draft',
|
||||
'workflow.schemaDraft.statusBadge.review_pending': 'Review',
|
||||
'workflow.schemaDraft.statusBadge.approved': 'Approved',
|
||||
'workflow.schemaDraft.statusBadge.changes_requested': 'Changes requested',
|
||||
'workflow.schemaDraft.statusBadge.rejected': 'Rejected',
|
||||
'workflow.schemaDraft.statusBadge.published': 'Published',
|
||||
'workflow.schemaDraft.statusBadge.withdrawn': 'Withdrawn',
|
||||
'workflow.schemaDraft.comment.decision': 'Reviewer comment',
|
||||
'workflow.schemaDraft.comment.publish': 'Release note',
|
||||
'workflow.schemaDraft.comment.note': 'Author note',
|
||||
'workflow.schemaDraft.comment.decisionPlaceholder': 'Brief decision rationale (optional)',
|
||||
'workflow.schemaDraft.comment.publishPlaceholder': 'What this version changes (lands in changelog)',
|
||||
'workflow.schemaDraft.comment.notePlaceholder': 'What to highlight for the reviewer',
|
||||
'workflow.schemaDraft.actions.submit': 'Submit for review',
|
||||
'workflow.schemaDraft.actions.resubmit': 'Revise & resubmit',
|
||||
'workflow.schemaDraft.actions.approve': 'Approve',
|
||||
'workflow.schemaDraft.actions.requestChanges': 'Request changes',
|
||||
'workflow.schemaDraft.actions.reject': 'Reject',
|
||||
'workflow.schemaDraft.actions.publish': 'Publish',
|
||||
'workflow.schemaDraft.actions.withdraw': 'Withdraw',
|
||||
'workflow.schemaDraft.toast.submitted': 'Submitted for review',
|
||||
'workflow.schemaDraft.toast.approve': 'Draft approved',
|
||||
'workflow.schemaDraft.toast.request_changes': 'Changes requested',
|
||||
'workflow.schemaDraft.toast.reject': 'Draft rejected',
|
||||
'workflow.schemaDraft.toast.published': 'Published v{{version}}',
|
||||
'workflow.schemaDraft.toast.withdrawn': 'Draft withdrawn',
|
||||
'workflow.schemaDraft.toast.submitFailed': 'Failed to submit for review',
|
||||
'workflow.schemaDraft.toast.decideFailed': 'Failed to record decision',
|
||||
'workflow.schemaDraft.toast.publishFailed': 'Failed to publish',
|
||||
'workflow.schemaDraft.toast.withdrawFailed': 'Failed to withdraw',
|
||||
// === Lineage (dict-relationships-v2 Phase 1) ===
|
||||
'lineage.usedBy.title': 'Used by',
|
||||
'lineage.usedBy.count_one': '{{count}} reference',
|
||||
|
||||
Reference in New Issue
Block a user