feat(approval): Approval Workflow v2 Phase 2 — admin UI reviewer queue + toggle

Phase 2 admin UI на скелете Phase 0/1 (e438c4c, 2020af9). Делает workflow
end-to-end usable: reviewer'ы могут approve/reject через UI; admins
включают approval_required per-dict через checkbox.

API surface (admin-ui):
- types: DraftStatus, DraftOperation, DraftResponse, SubmitDraftRequest,
  ReviewQueuePage. + DictionaryDefinition: approvalRequired, approvalMinRole.
- queries (react-query):
  * useReviewQueue (page, size) — global queue, refetch 30s.
  * useDraft(id) — single draft for diff drawer.
  * useLiveRecord(dict, bk) — current live record for side-by-side comparison.
- mutations:
  * useSubmitDraft(dict) — POST /dictionaries/{n}/records/drafts
  * useApproveDraft() — POST /drafts/{id}/approve
  * useRejectDraft() — POST /drafts/{id}/reject (reason required)
  * useWithdrawDraft() — DELETE /drafts/{id}

New /reviews route:
- Table queue: dict, business_key, operation badge, maker, submitted_at.
- Click "Просмотр" → side-by-side drawer:
  * left: current live (если есть; для CREATE — placeholder).
  * right: proposed (или CLOSE notice).
  * maker comment chip если есть.
  * Reviewer comment input + Approve / Reject buttons.
  * Reject disabled пока comment пустой (mirror's backend 400).
- Auto-invalidates на success — queue refresh, list updates.
- Nav link "Согласования" в header.

DictionaryEditorDialog Metadata tab:
- New checkbox "Требовать approval (maker-checker)" — orbit/8 styled
  (отделимо от Redis projection card visually).
- При checked → shown approval_min_role TextInput (e.g. "ordinis:internal").
- payload sends approvalRequired + approvalMinRole.

i18n RU/EN:
- nav.reviews, reviews.{title,description,empty,queueTotal_*,col.*,
  action.*, drawer.*}
- schema.approvalRequired.{label,hint}, schema.approvalMinRole.{label,hint}
- Plurals для RU queueTotal (one/few/many/other).

Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
- routeTree.gen.ts auto-regenerated с /reviews.

Phase 3 (next session):
- e2e tests: full flow maker submit → checker approve → record live + outbox event.
- e2e: reject keeps draft, doesn't publish.
- e2e: self-approve blocked (no_self_approve constraint).
- e2e: two pending drafts на one business_key blocked (one_pending_per_key).
- Per-dict opt-in soak: 1-2 dicts (например ground_station) с
  approval_required=true в staging.
This commit is contained in:
Zimin A.N.
2026-05-08 13:28:56 +03:00
parent 2020af99b7
commit 84a4de4ceb
8 changed files with 640 additions and 0 deletions
@@ -75,6 +75,12 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
const [redisProjection, setRedisProjection] = useState(
initial?.redisProjectionEnabled ?? false,
)
const [approvalRequired, setApprovalRequired] = useState(
(initial as { approvalRequired?: boolean } | null)?.approvalRequired ?? false,
)
const [approvalMinRole, setApprovalMinRole] = useState(
(initial as { approvalMinRole?: string } | null)?.approvalMinRole ?? '',
)
const [properties, setProperties] = useState<PropertyDef[]>(parsed.properties)
const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '')
const [nameError, setNameError] = useState<string | null>(null)
@@ -161,6 +167,8 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
supportedLocales: supportedLocales.length > 0 ? supportedLocales : undefined,
defaultLocale: defaultLocale || undefined,
redisProjectionEnabled: redisProjection,
approvalRequired,
approvalMinRole: approvalMinRole.trim() || undefined,
}
if (isEdit) {
@@ -308,6 +316,46 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
</span>
</span>
</label>
{/* Approval Workflow v2: per-dict opt-in. Default false. Включается
для критичных dicts (ground_station, spacecraft и т.д.) где
один admin не должен иметь power публиковать без review. */}
<label className="md:col-span-2 flex items-start gap-3 px-3 py-2 rounded-sm border border-orbit/40 bg-orbit/8 cursor-pointer">
<input
type="checkbox"
checked={approvalRequired}
onChange={(e) => setApprovalRequired(e.target.checked)}
className="mt-0.5 size-4 accent-orbit"
/>
<span className="flex-1">
<span className="block text-sm font-primary text-carbon">
{t('schema.approvalRequired.label')}
</span>
<span className="block text-2xs text-carbon/60 mt-0.5">
{t('schema.approvalRequired.hint')}
</span>
</span>
</label>
{approvalRequired && (
<div className="md:col-span-2">
<label
htmlFor="approval-min-role"
className="text-2xs uppercase tracking-label text-carbon/70"
>
{t('schema.approvalMinRole.label')}
</label>
<TextInput
id="approval-min-role"
value={approvalMinRole}
onChange={(e) => setApprovalMinRole(e.target.value)}
placeholder="ordinis:internal"
/>
<p className="text-2xs text-carbon/60 mt-0.5">
{t('schema.approvalMinRole.hint')}
</p>
</div>
)}
</div>
</div>