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
@@ -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 'Черновик отклонён'
}
}