diff --git a/TODOS.md b/TODOS.md
new file mode 100644
index 0000000..d74e5b4
--- /dev/null
+++ b/TODOS.md
@@ -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 `
` 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.
diff --git a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx
index 79d09d5..7e7de59 100644
--- a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx
+++ b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx
@@ -20,6 +20,7 @@ import { dictionaryDetailQuery, useDictionaries } from '@/api/queries'
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
import { SchemaBuilder } from './SchemaBuilder'
import { EventsPreviewTab } from './EventsPreviewTab'
+import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
import {
buildSchemaJson,
diffSchemas,
@@ -84,6 +85,11 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
const [properties, setProperties] = useState(parsed.properties)
const [idSource, setIdSource] = useState(parsed.idSource ?? '')
const [nameError, setNameError] = useState(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,
// его schema + locale + scope копируются. Имя остаётся пустым (admin
@@ -431,12 +437,29 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
{serverError && {serverError} }
{requiresDraftFlow && (
-
+ setDraftHandoffOpen(true)}
+ >
+ {t('schema.approvalRequired.openDraftCta', {
+ defaultValue: 'Создать черновик с моими изменениями',
+ })}
+
+ }
+ >
{t('schema.approvalRequired.editorBlock', {
defaultValue:
- 'Этот словарь требует ревью изменений. Закройте редактор и нажмите ' +
- '«Создать черновик схемы» на странице справочника — там вы сможете ' +
- 'отправить эти изменения на согласование.',
+ 'Этот словарь требует maker-checker review. Откройте черновик — ' +
+ 'ваши текущие изменения будут предзаполнены, вам нужно только ' +
+ 'указать причину и отправить на согласование.',
})}
)}
@@ -456,6 +479,24 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
+ {/* 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 && (
+ 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()
+ }}
+ />
+ )}
)
}
diff --git a/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx b/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx
index fd41d27..7df5241 100644
--- a/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx
+++ b/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx
@@ -24,6 +24,15 @@ type Props = {
* CHANGES_REQUESTED — backend auto-transition CHANGES_REQUESTED → DRAFT.
*/
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. */
onCreated?: (draftId: string) => void
}
@@ -46,18 +55,31 @@ type Props = {
* вернёт detail в message.
*
*/
-export function CreateSchemaDraftModal({ open, onClose, detail, editDraft, onCreated }: Props) {
+export function CreateSchemaDraftModal({
+ open,
+ onClose,
+ detail,
+ editDraft,
+ initialProposedSchema,
+ onCreated,
+}: Props) {
const { t } = useTranslation()
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(
() =>
JSON.stringify(
- editDraft ? editDraft.proposedSchema : detail.schemaJson,
+ editDraft
+ ? editDraft.proposedSchema
+ : initialProposedSchema !== undefined
+ ? initialProposedSchema
+ : detail.schemaJson,
null,
2,
),
- [editDraft, detail.schemaJson],
+ [editDraft, initialProposedSchema, detail.schemaJson],
)
const initialReason = editDraft?.reason ?? ''
const [json, setJson] = useState(initialJson)
diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx
index 925d85d..4ea7b1d 100644
--- a/ordinis-admin-ui/src/routes/reviews.tsx
+++ b/ordinis-admin-ui/src/routes/reviews.tsx
@@ -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 | null | undefined,
+ after: Record | 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 && (
)}
@@ -523,16 +563,23 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
{draft && (
<>
-
- {draft.operation}
- {draft.businessKey}
-
- {t('reviews.drawer.maker')}:
-
-
- {t('reviews.drawer.submitted')}:{' '}
- {new Date(draft.submittedAt).toLocaleString()}
-
+ {/* 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. */}
+
+
+ {draft.operation}
+ {draft.businessKey}
+
+
+
+ {t('reviews.drawer.maker')}:
+
+
+ {t('reviews.drawer.submitted')}:{' '}
+ {new Date(draft.submittedAt).toLocaleString()}
+
+
{draft.makerComment && (
@@ -544,6 +591,56 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
)}
+ {/* 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 | null | undefined,
+ draft.data as Record | 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 (
+
+ {summary.added.length > 0 && (
+
+ +
+ {summary.added.length}
+
+ ({summary.added.join(', ')})
+
+
+ )}
+ {summary.removed.length > 0 && (
+
+ −
+ {summary.removed.length}
+
+ ({summary.removed.join(', ')})
+
+
+ )}
+ {summary.changed.length > 0 && (
+
+ ~
+ {summary.changed.length}
+
+ ({summary.changed.join(', ')})
+
+
+ )}
+
+ )
+ })()}
+