feat(schema-workflow): Phase 1b — SchemaDraftDrawer with status-aware actions

This commit is contained in:
Александр Зимин
2026-05-12 11:08:31 +00:00
parent 2a3f2099e5
commit dd3a2d97e5
6 changed files with 1065 additions and 3 deletions
+94
View File
@@ -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
+136
View File
@@ -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({
+97
View File
@@ -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 'Черновик отклонён'
}
}
+106
View File
@@ -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',