Merge branch 'feat/close-via-draft-when-approval-required' into 'main'

feat(records): close / bulk-close через draft когда approvalRequired

See merge request 2-6/2-6-4/terravault/ordinis!175
This commit is contained in:
Александр Зимин
2026-05-13 16:41:09 +00:00
@@ -456,8 +456,54 @@ function DictionaryDetail() {
// handleBulkExport больше не нужен — bulk export через InfoPanel "Экспорт..." // handleBulkExport больше не нужен — bulk export через InfoPanel "Экспорт..."
// → ExportModal автоматически pre-picks scope=selected когда selection непуст. // → ExportModal автоматически pre-picks scope=selected когда selection непуст.
const handleBulkClose = () => { const [bulkDraftRunning, setBulkDraftRunning] = useState(false)
const handleBulkClose = async () => {
if (selection.size === 0) return 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( bulkCloseMut.mutate(
{ {
businessKeys: Array.from(selection), businessKeys: Array.from(selection),
@@ -614,6 +660,25 @@ function DictionaryDetail() {
const handleClose = () => { const handleClose = () => {
if (edit.kind !== 'close-confirm') return if (edit.kind !== 'close-confirm') return
const targetKey = edit.record.businessKey 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( closeMut.mutate(
{ businessKey: targetKey, reason: closeReason || undefined }, { businessKey: targetKey, reason: closeReason || undefined },
{ {
@@ -1263,20 +1328,35 @@ function DictionaryDetail() {
<p className="text-body text-ink"> <p className="text-body text-ink">
{t('dict.confirmClose.body', { key: edit.record.businessKey })} {t('dict.confirmClose.body', { key: edit.record.businessKey })}
</p> </p>
{approvalRequired && (
<Alert variant="info" title={t('dict.confirmClose.approvalTitle', {
defaultValue: 'Закрытие пройдёт через ревью',
})}>
{t('dict.confirmClose.approvalBody', {
defaultValue: 'Этот справочник требует maker-checker approval. Будет создан CLOSE-draft, запись закроется после approve от reviewer\'а.',
})}
</Alert>
)}
<TextInput <TextInput
label={t('dict.confirmClose.reason')} label={t('dict.confirmClose.reason')}
placeholder={t('dict.confirmClose.reasonPlaceholder')} placeholder={t('dict.confirmClose.reasonPlaceholder')}
value={closeReason} value={closeReason}
onChange={(e) => setCloseReason(e.target.value)} onChange={(e) => setCloseReason(e.target.value)}
/> />
{serverErrorMessage(closeMut.error) && ( {serverErrorMessage(
<Alert variant="error">{serverErrorMessage(closeMut.error)}</Alert> approvalRequired ? submitDraftMut.error : closeMut.error,
) && (
<Alert variant="error">
{serverErrorMessage(
approvalRequired ? submitDraftMut.error : closeMut.error,
)}
</Alert>
)} )}
<div className="flex justify-end gap-2 pt-2 border-t border-line"> <div className="flex justify-end gap-2 pt-2 border-t border-line">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
disabled={closeMut.isPending} disabled={approvalRequired ? submitDraftMut.isPending : closeMut.isPending}
onClick={() => setEdit({ kind: 'closed' })} onClick={() => setEdit({ kind: 'closed' })}
> >
{t('form.cancel')} {t('form.cancel')}
@@ -1284,10 +1364,14 @@ function DictionaryDetail() {
<Button <Button
type="button" type="button"
variant="danger" variant="danger"
loading={closeMut.isPending} loading={approvalRequired ? submitDraftMut.isPending : closeMut.isPending}
onClick={handleClose} onClick={handleClose}
> >
{t('dict.action.close')} {approvalRequired
? t('dict.action.closeAsDraft', {
defaultValue: 'Отправить на ревью',
})
: t('dict.action.close')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1461,9 +1545,12 @@ function DictionaryDetail() {
setBulkResult(null) setBulkResult(null)
setBulkReason('') setBulkReason('')
}} }}
isPending={bulkCloseMut.isPending} isPending={approvalRequired ? bulkDraftRunning : bulkCloseMut.isPending}
serverError={serverErrorMessage(bulkCloseMut.error)} serverError={serverErrorMessage(
approvalRequired ? submitDraftMut.error : bulkCloseMut.error,
)}
result={bulkResult} result={bulkResult}
approvalMode={approvalRequired}
/> />
</Modal> </Modal>
</div> </div>
@@ -1588,6 +1675,9 @@ type BulkCloseDialogProps = {
isPending: boolean isPending: boolean
serverError: string | null serverError: string | null
result: BulkCloseResponse | null result: BulkCloseResponse | null
/** Approval Workflow v2: на approval-required dict bulk-close шлёт N drafts
* CLOSE-операции вместо прямого закрытия. UI меняет copy + summary semantics. */
approvalMode?: boolean
} }
function BulkCloseDialog({ function BulkCloseDialog({
@@ -1599,6 +1689,7 @@ function BulkCloseDialog({
isPending, isPending,
serverError, serverError,
result, result,
approvalMode = false,
}: BulkCloseDialogProps) { }: BulkCloseDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -1656,6 +1747,19 @@ function BulkCloseDialog({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-body text-ink">{t('dict.bulk.confirmBody', { count })}</p> <p className="text-body text-ink">{t('dict.bulk.confirmBody', { count })}</p>
{approvalMode && (
<Alert
variant="info"
title={t('dict.bulk.approvalTitle', {
defaultValue: 'Закрытие пройдёт через ревью',
})}
>
{t('dict.bulk.approvalBody', {
count,
defaultValue: `${count} CLOSE-draft(ов) будут отправлены на ревью. Записи закроются после approve.`,
})}
</Alert>
)}
<TextInput <TextInput
label={t('dict.confirmClose.reason')} label={t('dict.confirmClose.reason')}
placeholder={t('dict.confirmClose.reasonPlaceholder')} placeholder={t('dict.confirmClose.reasonPlaceholder')}
@@ -1679,7 +1783,12 @@ function BulkCloseDialog({
onClick={onSubmit} onClick={onSubmit}
leftIcon={<TrashIcon weight="bold" size={14} />} leftIcon={<TrashIcon weight="bold" size={14} />}
> >
{t('dict.bulk.confirmAction', { count })} {approvalMode
? t('dict.bulk.confirmActionAsDraft', {
count,
defaultValue: `Отправить ${count} CLOSE-draft(ов) на ревью`,
})
: t('dict.bulk.confirmAction', { count })}
</Button> </Button>
</div> </div>
</div> </div>