feat(records): bulk close — multi-select toolbar + per-record транзакция

Backend POST /api/v1/dictionaries/{name}/records/bulk-close принимает
businessKeys[] (1..500) + reason. BulkRecordService крутит цикл через
inject'ed DictionaryRecordService — каждый close в своей транзакции
(SERIALIZABLE), partial success возможен. Result: closed[] / skipped[]
(already_closed, not_found) / errors[].

Frontend: checkbox column + sticky toolbar когда selection.size > 0.
Header checkbox с indeterminate state для partial select. selectAll
включает все ВИДИМЫЕ ключи (не игнорирует фильтры). При изменении
фильтра — useEffect выкидывает невидимые ключи из selection (WYSIWYG).

Bulk close dialog: count + reason input → confirm → результат панель
"Закрыто N, пропущено M, ошибок K" с детальным списком skipped/errors.
После успеха selection остаётся для skipped/errors — юзер видит что
ещё не сделано.

Лимит 500: защита от случайного огромного селекта или бага UI.
Дедупликация внутри batch'а silent skip — не error.
This commit is contained in:
Zimin A.N.
2026-05-07 11:19:57 +03:00
parent 92507ab0a1
commit 5941207955
8 changed files with 526 additions and 23 deletions
+12
View File
@@ -113,6 +113,18 @@ export type CreateRecordRequest = {
validTo?: string
}
export type BulkCloseRequest = {
businessKeys: string[]
at?: string
reason?: string
}
export type BulkCloseResponse = {
closed: string[]
skipped: { businessKey: string; reason: string }[]
errors: { businessKey: string; reason: string }[]
}
export type AuditAction = 'CREATE' | 'UPDATE' | 'CLOSE'
export type AuditEntry = {
+22
View File
@@ -1,6 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
apiClient,
type BulkCloseRequest,
type BulkCloseResponse,
type CreateDictionaryRequest,
type CreateRecordRequest,
type CreateWebhookSubscriptionRequest,
@@ -101,6 +103,26 @@ export const useCloseRecord = (dictionaryName: string) => {
})
}
/**
* Bulk close: закрыть N records одним запросом. Backend per-record транзакция,
* partial success возможен (already_closed / not_found попадает в skipped).
*/
export const useBulkCloseRecords = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (req: BulkCloseRequest): Promise<BulkCloseResponse> => {
const { data } = await apiClient.post<BulkCloseResponse>(
`/dictionaries/${dictionaryName}/records/bulk-close`,
req,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
},
})
}
// === Webhooks (v2) ===
export const useCreateWebhook = () => {