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
+30
View File
@@ -145,6 +145,22 @@ i18n
'Запись «{{key}}» будет помечена как закрытая (valid_to = now). Действие обратимо через создание новой версии.',
'dict.confirmClose.reason': 'Причина (опционально)',
'dict.confirmClose.reasonPlaceholder': 'например, «дубликат» или «выведено из эксплуатации»',
'dict.bulk.selectAll': 'Выбрать все на странице',
'dict.bulk.selectRow': 'Выбрать запись',
'dict.bulk.selectedCount_one': 'Выбрано {{count}} из {{total}}',
'dict.bulk.selectedCount_few': 'Выбрано {{count}} из {{total}}',
'dict.bulk.selectedCount_many': 'Выбрано {{count}} из {{total}}',
'dict.bulk.selectedCount_other': 'Выбрано {{count}} из {{total}}',
'dict.bulk.clearSelection': 'Снять выделение',
'dict.bulk.closeAction': 'Закрыть выбранные',
'dict.bulk.confirmTitle': 'Массовое закрытие записей',
'dict.bulk.confirmBody':
'{{count}} запис(ей) будут помечены как закрытые (valid_to = now). Действие обратимо через создание новой версии. Каждая запись закрывается отдельно — если часть уже закрыта, остальные обработаются.',
'dict.bulk.confirmAction': 'Закрыть {{count}}',
'dict.bulk.resultClosed': 'Закрыто: {{count}}',
'dict.bulk.resultSkipped': 'Пропущено (уже закрыто или не найдено): {{count}}',
'dict.bulk.resultErrors': 'Ошибок: {{count}}',
'dict.bulk.dismiss': 'Готово',
'form.identity': 'Идентификация',
'form.idSourceNote': 'Бизнес-ключ создастся автоматически из поля «{{field}}»',
'form.tabs.identity': 'Идентификация',
@@ -410,6 +426,20 @@ i18n
'Record "{{key}}" will be marked as closed (valid_to = now). Reversible by creating a new version.',
'dict.confirmClose.reason': 'Reason (optional)',
'dict.confirmClose.reasonPlaceholder': 'e.g., "duplicate" or "decommissioned"',
'dict.bulk.selectAll': 'Select all on page',
'dict.bulk.selectRow': 'Select record',
'dict.bulk.selectedCount_one': '{{count}} of {{total}} selected',
'dict.bulk.selectedCount_other': '{{count}} of {{total}} selected',
'dict.bulk.clearSelection': 'Clear selection',
'dict.bulk.closeAction': 'Close selected',
'dict.bulk.confirmTitle': 'Bulk close records',
'dict.bulk.confirmBody':
'{{count}} record(s) will be marked as closed (valid_to = now). Reversible by creating a new version. Each record is closed individually — if some are already closed, others will still be processed.',
'dict.bulk.confirmAction': 'Close {{count}}',
'dict.bulk.resultClosed': 'Closed: {{count}}',
'dict.bulk.resultSkipped': 'Skipped (already closed or not found): {{count}}',
'dict.bulk.resultErrors': 'Errors: {{count}}',
'dict.bulk.dismiss': 'Done',
'form.identity': 'Identity',
'form.idSourceNote': 'Business key auto-derived from "{{field}}" field',
'form.tabs.identity': 'Identity',