feat(approval-ux): inline draft CTA + reviewer diff summary + tightened drawer

Acts on the two "build now" items from the design review of the approval
workflow UX (other five went to TODOS.md). Plus the TODOS.md file itself,
authored during the same review.

1. **Inline "Создать черновик с моими изменениями" CTA**
   (DictionaryEditorDialog + CreateSchemaDraftModal)

   Was: when a maker edited an approval-required dict, Save disabled and a
   warning told them to "close the editor and click 'Создать черновик схемы'
   on the dict page." Three friction steps and a hunt for a button.

   Now: the warning Alert carries an action button — "Создать черновик с
   моими изменениями" — that opens CreateSchemaDraftModal in-place with the
   maker's in-progress schemaJson pre-loaded. They add a reason and submit.
   No retyping, no hunt.

   Implementation:
   - CreateSchemaDraftModal accepts optional `initialProposedSchema` prop.
     Seed order: edit-mode draft → caller pre-fill → live HEAD.
   - DictionaryEditorDialog mounts the modal inline and triggers it from the
     Alert action. Closes the editor on successful create.

2. **Diff summary chips in ReviewDrawer**

   Was: two raw JSON dumps side by side. Reviewer had to eyeball-diff long
   records to find the actual edit.

   Now: a row of summary chips above the panes — "+N added, −M removed,
   ~K changed" with the field names inline. Computed shallow client-side
   (shallowDiffSummary helper) — ordinis records are flat-ish at the top
   level, no need for a backend diff endpoint. Skipped for CLOSE op (no
   proposed) and when both sides are null.

3. **Tightened drawer header + trimmed empty-state copy**

   Was: single flex-wrap line with four spans (badge + bk + maker + ts) —
   visually busy. Plus a verbose empty-state description: "Когда maker
   отправит schema draft на ревью, он появится здесь."

   Now: two-row layout (identity row on top, provenance row below) so the
   eye lands on Badge + BK first. Empty-state copy shortened to "Очередь
   пуста" / "Схемы на ревью появятся здесь."

Other five gaps (Мои tab, full state-coverage pass, DESIGN.md, a11y audit,
maker notifications) recorded in TODOS.md.
This commit is contained in:
Andrei Zimin
2026-05-14 15:56:50 +03:00
parent bdae52ff28
commit fe7f9cd01f
4 changed files with 339 additions and 20 deletions
+109 -12
View File
@@ -40,6 +40,46 @@ const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' =>
return 'info'
}
/**
* Shallow diff summary between two record JSON payloads. Reviewer sees three
* counts above the side-by-side dump — "+N added, M removed, ~K changed" —
* so they don't have to eyeball-diff long records to find the actual edit.
*
* Shallow on purpose: ordinis records are flat-ish dicts at the top level
* (business fields, no deep object trees). Nested object changes count as
* one "changed" entry on the outer key. Good enough for reviewer at-a-glance.
*
* Returns null when before/after both null (CLOSE op, or first CREATE) —
* caller can skip the summary row entirely.
*/
type DraftDiffSummary = {
added: string[]
removed: string[]
changed: string[]
}
function shallowDiffSummary(
before: Record<string, unknown> | null | undefined,
after: Record<string, unknown> | null | undefined,
): DraftDiffSummary | null {
if (!before && !after) return null
const b = before ?? {}
const a = after ?? {}
const added: string[] = []
const removed: string[] = []
const changed: string[] = []
for (const k of Object.keys(a)) {
if (!(k in b)) {
added.push(k)
} else if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) {
changed.push(k)
}
}
for (const k of Object.keys(b)) {
if (!(k in a)) removed.push(k)
}
return { added, removed, changed }
}
/**
* Approval Workflow v2 — reviewer queue (D3=A pool model).
*
@@ -192,10 +232,10 @@ function ReviewsPage() {
{schemasCount === 0 && !schemaQueue.isLoading && (
<EmptyState
title={t('reviews.schema.empty', {
defaultValue: 'Нет схем на ревью',
defaultValue: 'Очередь пуста',
})}
description={t('reviews.schema.emptyDescription', {
defaultValue: 'Когда maker отправит schema draft на ревью, он появится здесь.',
defaultValue: 'Схемы на ревью появятся здесь.',
})}
/>
)}
@@ -523,16 +563,23 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
{draft && (
<>
<div className="flex flex-wrap items-center gap-2 text-cell">
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
<span className="font-mono">{draft.businessKey}</span>
<span className="text-mute inline-flex items-center gap-1">
{t('reviews.drawer.maker')}: <UserCell uuid={draft.makerId} />
</span>
<span className="text-mute">
{t('reviews.drawer.submitted')}:{' '}
{new Date(draft.submittedAt).toLocaleString()}
</span>
{/* Tightened header: top row is identity (op + bk), bottom row is
provenance (who/when). Was a single flex-wrap line with four
spans — visually busy, hard to scan. */}
<div className="space-y-1 text-cell">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
<span className="font-mono">{draft.businessKey}</span>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-mute">
<span className="inline-flex items-center gap-1">
{t('reviews.drawer.maker')}: <UserCell uuid={draft.makerId} />
</span>
<span>
{t('reviews.drawer.submitted')}:{' '}
{new Date(draft.submittedAt).toLocaleString()}
</span>
</div>
</div>
{draft.makerComment && (
@@ -544,6 +591,56 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
</div>
)}
{/* Diff summary chips above the side-by-side dump — reviewer sees
"+1 added, ~2 changed" before eyeballing the JSON. Skipped for
CLOSE op (no proposed) and when both sides are null. */}
{(() => {
const summary =
draft.operation === 'CLOSE'
? null
: shallowDiffSummary(
liveQ.data?.data as Record<string, unknown> | null | undefined,
draft.data as Record<string, unknown> | null | undefined,
)
if (!summary) return null
const empty =
summary.added.length === 0 &&
summary.removed.length === 0 &&
summary.changed.length === 0
if (empty) return null
return (
<div className="flex flex-wrap items-center gap-3 text-mono text-cap tabular-nums">
{summary.added.length > 0 && (
<span className="inline-flex items-center gap-1 text-aurora">
<span className="font-bold">+</span>
<span>{summary.added.length}</span>
<span className="text-mute lowercase">
({summary.added.join(', ')})
</span>
</span>
)}
{summary.removed.length > 0 && (
<span className="inline-flex items-center gap-1 text-danger">
<span className="font-bold"></span>
<span>{summary.removed.length}</span>
<span className="text-mute lowercase">
({summary.removed.join(', ')})
</span>
</span>
)}
{summary.changed.length > 0 && (
<span className="inline-flex items-center gap-1 text-warn">
<span className="font-bold">~</span>
<span>{summary.changed.length}</span>
<span className="text-mute lowercase">
({summary.changed.join(', ')})
</span>
</span>
)}
</div>
)
})()}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<p className="text-cap text-mute mb-1">