feat(records): close / bulk-close через draft когда approvalRequired
This commit is contained in:
@@ -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() {
|
||||
<p className="text-body text-ink">
|
||||
{t('dict.confirmClose.body', { key: edit.record.businessKey })}
|
||||
</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
|
||||
label={t('dict.confirmClose.reason')}
|
||||
placeholder={t('dict.confirmClose.reasonPlaceholder')}
|
||||
value={closeReason}
|
||||
onChange={(e) => setCloseReason(e.target.value)}
|
||||
/>
|
||||
{serverErrorMessage(closeMut.error) && (
|
||||
<Alert variant="error">{serverErrorMessage(closeMut.error)}</Alert>
|
||||
{serverErrorMessage(
|
||||
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">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={closeMut.isPending}
|
||||
disabled={approvalRequired ? submitDraftMut.isPending : closeMut.isPending}
|
||||
onClick={() => setEdit({ kind: 'closed' })}
|
||||
>
|
||||
{t('form.cancel')}
|
||||
@@ -1284,10 +1364,14 @@ function DictionaryDetail() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
loading={closeMut.isPending}
|
||||
loading={approvalRequired ? submitDraftMut.isPending : closeMut.isPending}
|
||||
onClick={handleClose}
|
||||
>
|
||||
{t('dict.action.close')}
|
||||
{approvalRequired
|
||||
? t('dict.action.closeAsDraft', {
|
||||
defaultValue: 'Отправить на ревью',
|
||||
})
|
||||
: t('dict.action.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
label={t('dict.confirmClose.reason')}
|
||||
placeholder={t('dict.confirmClose.reasonPlaceholder')}
|
||||
@@ -1679,7 +1783,12 @@ function BulkCloseDialog({
|
||||
onClick={onSubmit}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user