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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user