From d66db60b8496a218501ebade77ff68cc232849d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Wed, 13 May 2026 16:41:09 +0000 Subject: [PATCH] =?UTF-8?q?feat(records):=20close=20/=20bulk-close=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20draft=20=D0=BA=D0=BE=D0=B3?= =?UTF-8?q?=D0=B4=D0=B0=20approvalRequired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/dictionaries.$name.tsx | 127 ++++++++++++++++-- 1 file changed, 118 insertions(+), 9 deletions(-) diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index b430fe6..0dbd27d 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -456,8 +456,54 @@ function DictionaryDetail() { // handleBulkExport больше не нужен — bulk export через InfoPanel "Экспорт..." // → ExportModal автоматически pre-picks scope=selected когда selection непуст. - const handleBulkClose = () => { + const [bulkDraftRunning, setBulkDraftRunning] = useState(false) + const handleBulkClose = async () => { if (selection.size === 0) return + if (approvalRequired) { + // Approval Workflow v2: на approval-required dict bulk-close ходит + // не через POST /records/_bulk_close (вернёт 409 draft_required для + // каждого), а через N отдельных draft submissions (operation=CLOSE). + // Backend сейчас не имеет _bulk endpoint для drafts, делаем Promise.all + // на клиенте — N drafts создаются параллельно, результат собирается + // в тот же BulkCloseResponse shape. + setBulkDraftRunning(true) + const targets = Array.from(selection) + const result: BulkCloseResponse = { closed: [], skipped: [], errors: [] } + await Promise.all( + targets.map(async (bk) => { + try { + await submitDraftMut.mutateAsync({ + businessKey: bk, + operation: 'CLOSE', + comment: bulkReason || null, + }) + // 'closed' семантически здесь = 'submitted as CLOSE draft'. + // Frontend label показывает «отправлено на ревью» когда approvalRequired. + result.closed.push(bk) + } catch (e: unknown) { + const code = + (e as { response?: { data?: { code?: string } } })?.response + ?.data?.code ?? + (e as Error)?.message ?? + 'unknown' + // draft_already_pending — этот key уже на ревью, skip (не error). + if (code === 'draft_already_pending') { + result.skipped.push({ businessKey: bk, reason: code }) + } else { + result.errors.push({ businessKey: bk, reason: code }) + } + } + }), + ) + setBulkResult(result) + setSelection((prev) => { + const next = new Set(prev) + for (const k of result.closed) next.delete(k) + return next + }) + setBulkDraftRunning(false) + return + } bulkCloseMut.mutate( { businessKeys: Array.from(selection), @@ -614,6 +660,25 @@ function DictionaryDetail() { const handleClose = () => { if (edit.kind !== 'close-confirm') return const targetKey = edit.record.businessKey + if (approvalRequired) { + // Approval Workflow v2: close тоже через draft (operation=CLOSE). + // Maker подаёт CLOSE proposal, reviewer'у достаточно approve и запись + // закроется автоматически через recordService.applyApprovedClose. + submitDraftMut.mutate( + { + businessKey: targetKey, + operation: 'CLOSE', + comment: closeReason || null, + }, + { + onSuccess: () => { + setEdit({ kind: 'closed' }) + setCloseReason('') + }, + }, + ) + return + } closeMut.mutate( { businessKey: targetKey, reason: closeReason || undefined }, { @@ -1263,20 +1328,35 @@ function DictionaryDetail() {

{t('dict.confirmClose.body', { key: edit.record.businessKey })}

+ {approvalRequired && ( + + {t('dict.confirmClose.approvalBody', { + defaultValue: 'Этот справочник требует maker-checker approval. Будет создан CLOSE-draft, запись закроется после approve от reviewer\'а.', + })} + + )} setCloseReason(e.target.value)} /> - {serverErrorMessage(closeMut.error) && ( - {serverErrorMessage(closeMut.error)} + {serverErrorMessage( + approvalRequired ? submitDraftMut.error : closeMut.error, + ) && ( + + {serverErrorMessage( + approvalRequired ? submitDraftMut.error : closeMut.error, + )} + )}
@@ -1461,9 +1545,12 @@ function DictionaryDetail() { setBulkResult(null) setBulkReason('') }} - isPending={bulkCloseMut.isPending} - serverError={serverErrorMessage(bulkCloseMut.error)} + isPending={approvalRequired ? bulkDraftRunning : bulkCloseMut.isPending} + serverError={serverErrorMessage( + approvalRequired ? submitDraftMut.error : bulkCloseMut.error, + )} result={bulkResult} + approvalMode={approvalRequired} /> @@ -1588,6 +1675,9 @@ type BulkCloseDialogProps = { isPending: boolean serverError: string | null result: BulkCloseResponse | null + /** Approval Workflow v2: на approval-required dict bulk-close шлёт N drafts + * CLOSE-операции вместо прямого закрытия. UI меняет copy + summary semantics. */ + approvalMode?: boolean } function BulkCloseDialog({ @@ -1599,6 +1689,7 @@ function BulkCloseDialog({ isPending, serverError, result, + approvalMode = false, }: BulkCloseDialogProps) { const { t } = useTranslation() @@ -1656,6 +1747,19 @@ function BulkCloseDialog({ return (

{t('dict.bulk.confirmBody', { count })}

+ {approvalMode && ( + + {t('dict.bulk.approvalBody', { + count, + defaultValue: `${count} CLOSE-draft(ов) будут отправлены на ревью. Записи закроются после approve.`, + })} + + )} } > - {t('dict.bulk.confirmAction', { count })} + {approvalMode + ? t('dict.bulk.confirmActionAsDraft', { + count, + defaultValue: `Отправить ${count} CLOSE-draft(ов) на ревью`, + }) + : t('dict.bulk.confirmAction', { count })}