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
+159
View File
@@ -0,0 +1,159 @@
# TODOS
Design debt and follow-ups surfaced by `/plan-design-review` of the approval
workflow UX (2026-05-14). Each entry is self-contained: pick one up and read
the **Context** to understand the motivation without re-running the review.
---
## 1. `/reviews` "Мои" tab — maker visibility
**What.** Add a third tab "Мои" on the `/reviews` page with three sub-sections:
Pending (drafts awaiting review), Decided (drafts that received a verdict),
WIP (drafts the maker started but did not yet submit).
**Why.** Today the maker has no surface to see "where are my drafts?". MR !170
added the backend (`listByMaker` + analogous schema endpoint), but the UI never
got the entry point. Reviewer's queue mixes records and schemas — symmetric
"my drafts" view is missing.
**Pros.** Reuses `/reviews` page chrome. Backend endpoints already exist. Closes
the loop on the maker journey ("submit → ??? → ..." now has a destination).
**Cons.** Adds two more list queries to the page. Need to think about whether
the existing reviewer "Записи / Схемы" toggle nests inside "Мои" too (so 6 panels
total) or "Мои" is flat.
**Context.** Review pass 1 rated information architecture 6/10 specifically
because maker visibility was missing. User chose "third tab on /reviews"
over a separate `/my-drafts` route — keeps everything in one place.
**Depends on / blocked by.** Nothing — endpoints live (`/admin/dictionaries/*/drafts?makerId=...`
and analogous for schemas).
---
## 2. Interaction state pass — reviews page polish
**What.** One MR covering five small fixes:
- Records empty state: add description + primary action ("Queue is clear, see
/audit for past decisions"). Current `<EmptyState title={t('reviews.empty')} />`
is just a heading.
- Reject reason inline validation: today `handleReject` does
`if (!comment.trim()) return` silently. Button click → nothing happens. Show
inline error or set the TextArea into error state.
- Drawer empty state (`workflow.schemaDraft.empty`): distinguish "not loaded",
"not found" (deleted), "no permission". Today the same blank message covers
all three.
- Approve action: drawer closes immediately on success. Add a 500ms success
state (or toast) so the reviewer gets visual confirmation. Currently feels
like the click might not have registered.
- Post-bulk-result polish: the `bulkResult` Alert lists business keys but the
failed list shows raw `reason` codes (`self_approve_forbidden`,
`draft_not_pending`). Map through `extractReviewError` already in the file.
**Why.** Pass 2 rated state coverage 5/10. These are the visible gaps. Each
is 10-30 LOC; the value is interaction confidence.
**Pros.** All in one place — `reviews.tsx`, `SchemaDraftDrawer.tsx`. Low risk.
**Cons.** Five different surfaces in one MR means review surface is wider than
a single-purpose change. Still worth grouping because they're all "state
polish" semantically.
**Context.** Pass 2 finding. None of these block functionality; they're trust
builders. Reject silent fail is the most user-visible — a reviewer clicks the
button and nothing happens, then notices the TextArea is empty after a beat.
**Depends on / blocked by.** Nothing.
---
## 5. DESIGN.md for the admin UI
**What.** Run `/design-consultation` skill or stub DESIGN.md manually to lock
the implicit design system: drawer-vs-modal usage rules, term glossary
(maker / reviewer / publisher and their Russian counterparts), Badge variant
mapping (CREATE → success, UPDATE → info, CLOSE → warning — already used
consistently but undocumented), code panes pattern (live = `bg-line/30`,
proposed = `bg-accent/4`).
**Why.** Pass 5 rated design system alignment 4/10. The system *exists* — design
tokens are used consistently — but it's tribal knowledge. Next developer building
a feature like "Phase 4 webhook approvals" will guess and either match by luck
or drift.
**Pros.** Half-day work. Pays off on every new feature. Source of truth for
`/plan-design-review` calibration on future plans.
**Cons.** Documentation has to be maintained or it lies. Mitigated by keeping
DESIGN.md to invariants (tokens, primitives, semantic rules), not specific
components.
**Context.** User chose "defer to /design-consultation sprint" instead of
stubbing inline. The decision is to do it properly when the sprint happens,
not piecemeal during this review.
**Depends on / blocked by.** Nothing.
---
## 6. A11y audit post-deploy via `/design-review`
**What.** After the in-flight approval workflow MRs deploy to staging, run
`/design-review` skill against the live `/reviews` page + DictionaryEditorDialog
flow. The skill does Lighthouse, axe, screen reader walk-through, contrast
audit, keyboard navigation testing.
**Why.** Pass 6 rated responsive/a11y 5/10. Specific suspects:
- Button heights not verified at 44px touch target.
- `text-mute` contrast against `bg-surface` — might fail WCAG AA 4.5:1.
- Reject reason TextArea has no specified `minHeight`.
- Bulk action toolbar wrap behavior at 375px.
- Diff `<pre>` panes have no aria-label — screen reader reads JSON literally.
- Keyboard tab order through long diff panes — reviewer may have to tab 50 times
to reach approve buttons.
**Pros.** Necessary for compliance contexts (gov contracts, accessibility laws).
Catches things static review misses (real screen reader, real touch device).
**Cons.** Requires the in-flight MRs to be deployed first. Cannot do in
plan mode.
**Context.** User chose "defer to /design-review post-deploy". The audit will
produce its own punch list with severity ratings.
**Depends on / blocked by.** MRs !179, !180, !182, !183, !184, !185 deployed
to staging.
---
## 7. Notifications service: draft decision toast + email
**What.** Notifications service subscribes to outbox `RecordDraftApproved /
RecordDraftRejected / RecordDraftWithdrawn` events (now fired thanks to
MR !184) and:
1. Fires email to maker with reviewer comment.
2. Sets a flag (DB or Redis) so on next login the admin UI shows a toast:
"Your draft for `test1/BK-42` was approved by reviewer X. The record is
now live."
3. Bell icon in the header gains a badge counter for unread decisions.
**Why.** Maker has no synchronous feedback. They submit, then either:
- Tab "Мои" → Decided (covered by TODO 1) — but only when they come back
- This TODO — push notification so they know without re-visiting
**Pros.** Closes the maker feedback loop. Reuses the outbox infra. Mirrors
existing notification flows in `ordinis-notifications`.
**Cons.** New email template + new in-app toast surface + bell badge. Half-day
to a day of work split between notifications service and admin-ui.
**Context.** Pass 7 unresolved decision. User chose "in-app toast + email via
notifications service" over the lighter "tab badge polling" option. Backend
events already fire (MR !184), so the gate is consumer side.
**Depends on / blocked by.** MR !184 deployed. TODO 1 ("Мои" tab) helps but
is not a hard blocker — the toast and email work without that surface.
@@ -20,6 +20,7 @@ import { dictionaryDetailQuery, useDictionaries } from '@/api/queries'
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client' import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
import { SchemaBuilder } from './SchemaBuilder' import { SchemaBuilder } from './SchemaBuilder'
import { EventsPreviewTab } from './EventsPreviewTab' import { EventsPreviewTab } from './EventsPreviewTab'
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
import { import {
buildSchemaJson, buildSchemaJson,
diffSchemas, diffSchemas,
@@ -84,6 +85,11 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
const [properties, setProperties] = useState<PropertyDef[]>(parsed.properties) const [properties, setProperties] = useState<PropertyDef[]>(parsed.properties)
const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '') const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '')
const [nameError, setNameError] = useState<string | null>(null) const [nameError, setNameError] = useState<string | null>(null)
/** Open state for the "Создать черновик с моими изменениями" CTA. The CTA
* is the inline handoff from the disabled-Save path (requiresDraftFlow)
* into the schema-draft modal with the maker's in-progress schemaJson
* pre-loaded — no retyping. */
const [draftHandoffOpen, setDraftHandoffOpen] = useState(false)
// Template (только в create-mode): admin выбирает существующий dict, // Template (только в create-mode): admin выбирает существующий dict,
// его schema + locale + scope копируются. Имя остаётся пустым (admin // его schema + locale + scope копируются. Имя остаётся пустым (admin
@@ -431,12 +437,29 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
{serverError && <Alert variant="error">{serverError}</Alert>} {serverError && <Alert variant="error">{serverError}</Alert>}
{requiresDraftFlow && ( {requiresDraftFlow && (
<Alert variant="warning"> <Alert
variant="warning"
title={t('schema.approvalRequired.editorBlockTitle', {
defaultValue: 'Изменения требуют ревью',
})}
action={
<Button
type="button"
variant="primary"
size="sm"
onClick={() => setDraftHandoffOpen(true)}
>
{t('schema.approvalRequired.openDraftCta', {
defaultValue: 'Создать черновик с моими изменениями',
})}
</Button>
}
>
{t('schema.approvalRequired.editorBlock', { {t('schema.approvalRequired.editorBlock', {
defaultValue: defaultValue:
'Этот словарь требует ревью изменений. Закройте редактор и нажмите ' + 'Этот словарь требует maker-checker review. Откройте черновик — ' +
'«Создать черновик схемы» на странице справочника — там вы сможете ' + 'ваши текущие изменения будут предзаполнены, вам нужно только ' +
'отправить эти изменения на согласование.', 'указать причину и отправить на согласование.',
})} })}
</Alert> </Alert>
)} )}
@@ -456,6 +479,24 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
</Button> </Button>
</FormActions> </FormActions>
</div> </div>
{/* Inline handoff: CreateSchemaDraftModal mounted with the in-progress
schemaJson pre-loaded. Maker hits "Создать черновик с моими изменениями"
→ modal opens with the same edits they had → they add a reason and
submit. No retyping, no hunt for a button on the dict page. */}
{isEdit && initial && (
<CreateSchemaDraftModal
open={draftHandoffOpen}
onClose={() => setDraftHandoffOpen(false)}
detail={initial}
initialProposedSchema={schemaJson}
onCreated={() => {
setDraftHandoffOpen(false)
// Close the editor too — the maker's done editing live, the draft
// is now the source of truth until a reviewer decides.
onClose()
}}
/>
)}
</Modal> </Modal>
) )
} }
@@ -24,6 +24,15 @@ type Props = {
* CHANGES_REQUESTED — backend auto-transition CHANGES_REQUESTED → DRAFT. * CHANGES_REQUESTED — backend auto-transition CHANGES_REQUESTED → DRAFT.
*/ */
editDraft?: SchemaDraft editDraft?: SchemaDraft
/**
* Optional pre-filled proposed schema. When passed (and not in edit mode),
* the JSON editor seeds with this instead of `detail.schemaJson`. Used by
* the "Создать черновик с моими изменениями" CTA in DictionaryEditorDialog:
* the maker tweaks the schema, hits the disabled Save, then jumps straight
* into the draft modal with their in-progress edits already loaded. No
* retyping.
*/
initialProposedSchema?: unknown
/** Callback после create — parent может закрыть modal + открыть drawer. */ /** Callback после create — parent может закрыть modal + открыть drawer. */
onCreated?: (draftId: string) => void onCreated?: (draftId: string) => void
} }
@@ -46,18 +55,31 @@ type Props = {
* вернёт detail в message.</li> * вернёт detail в message.</li>
* </ul> * </ul>
*/ */
export function CreateSchemaDraftModal({ open, onClose, detail, editDraft, onCreated }: Props) { export function CreateSchemaDraftModal({
open,
onClose,
detail,
editDraft,
initialProposedSchema,
onCreated,
}: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const isEdit = Boolean(editDraft) const isEdit = Boolean(editDraft)
// В edit mode seed'им из draft'овой proposedSchema, иначе из live HEAD. // Seed order: edit-mode draft → caller-provided initialProposedSchema →
// live HEAD. Caller pre-fill wins over live HEAD so the maker's in-progress
// edits from DictionaryEditorDialog flow through.
const initialJson = useMemo( const initialJson = useMemo(
() => () =>
JSON.stringify( JSON.stringify(
editDraft ? editDraft.proposedSchema : detail.schemaJson, editDraft
? editDraft.proposedSchema
: initialProposedSchema !== undefined
? initialProposedSchema
: detail.schemaJson,
null, null,
2, 2,
), ),
[editDraft, detail.schemaJson], [editDraft, initialProposedSchema, detail.schemaJson],
) )
const initialReason = editDraft?.reason ?? '' const initialReason = editDraft?.reason ?? ''
const [json, setJson] = useState(initialJson) const [json, setJson] = useState(initialJson)
+102 -5
View File
@@ -40,6 +40,46 @@ const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' =>
return 'info' 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). * Approval Workflow v2 — reviewer queue (D3=A pool model).
* *
@@ -192,10 +232,10 @@ function ReviewsPage() {
{schemasCount === 0 && !schemaQueue.isLoading && ( {schemasCount === 0 && !schemaQueue.isLoading && (
<EmptyState <EmptyState
title={t('reviews.schema.empty', { title={t('reviews.schema.empty', {
defaultValue: 'Нет схем на ревью', defaultValue: 'Очередь пуста',
})} })}
description={t('reviews.schema.emptyDescription', { description={t('reviews.schema.emptyDescription', {
defaultValue: 'Когда maker отправит schema draft на ревью, он появится здесь.', defaultValue: 'Схемы на ревью появятся здесь.',
})} })}
/> />
)} )}
@@ -523,17 +563,24 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
{draft && ( {draft && (
<> <>
<div className="flex flex-wrap items-center gap-2 text-cell"> {/* 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> <Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
<span className="font-mono">{draft.businessKey}</span> <span className="font-mono">{draft.businessKey}</span>
<span className="text-mute inline-flex items-center gap-1"> </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} /> {t('reviews.drawer.maker')}: <UserCell uuid={draft.makerId} />
</span> </span>
<span className="text-mute"> <span>
{t('reviews.drawer.submitted')}:{' '} {t('reviews.drawer.submitted')}:{' '}
{new Date(draft.submittedAt).toLocaleString()} {new Date(draft.submittedAt).toLocaleString()}
</span> </span>
</div> </div>
</div>
{draft.makerComment && ( {draft.makerComment && (
<div className="px-3 py-2 rounded-sm border border-line bg-line/30 text-cell"> <div className="px-3 py-2 rounded-sm border border-line bg-line/30 text-cell">
@@ -544,6 +591,56 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
</div> </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 className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<p className="text-cap text-mute mb-1"> <p className="text-cap text-mute mb-1">