feat(records): close / bulk-close через draft когда approvalRequired
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user