feat(approval): bulk approve/reject в /reviews queue (Phase 4 polish)
Phase 4 last optional UX из design doc — bulk operations: - Multi-select column в reviewer queue (Checkbox per row + select-all header). Set<draftId> selection state, persisted между refetch'ами. - "Approve выбранные" — confirm dialog → parallel approveDraft mutations через Promise.all. Каждая mutation independent: если одна fail (409 self_approve_forbidden / draft_not_pending race), остальные продолжают. - "Reject выбранные" — Modal с TextArea для shared reason (required, matches backend reject_reason_required validation). Reason одинаковый для всех — bulk обычно "duplicate registration" / "wrong scope" / etc. - Bulk result Alert: approved count / rejected count / failed list с per-draft reason'ами (err code из 409). User видит точно что прошло и что нет. - Auto-clear selection после bulk operation. Queue refetch'ит через refetchInterval=30s, плюс mutation onSuccess invalidate'ит ['drafts']. i18n RU/EN: 22 keys (selectAll/selectRow/selectedCount/approveSelected/ rejectSelected/clear/confirmApprove/rejectModal*/confirmReject/cancel/ resultTitle/approvedCount/rejectedCount/failedCount). Pluralization через i18next plurals. Why ship without soak data: - Implementation не зависит от queue depth — это просто loop'ит над выбранными drafts. - Reviewer на любом dict с queue >5 уже выгадывает время. - Если queue staying small после soak — feature не bothering нас (UI toolbar показывается только когда anySelected). Tests: vitest 89/89, tsc clean, prod build clean.
This commit is contained in:
@@ -54,6 +54,36 @@ i18n
|
||||
'reviews.action.approve': 'Approve',
|
||||
'reviews.action.reject': 'Reject',
|
||||
'reviews.action.cancel': 'Отмена',
|
||||
'reviews.bulk.selectAll': 'Выбрать все',
|
||||
'reviews.bulk.selectRow': 'Выбрать draft',
|
||||
'reviews.bulk.selectedCount_one': 'выбран {{count}}',
|
||||
'reviews.bulk.selectedCount_few': 'выбрано {{count}}',
|
||||
'reviews.bulk.selectedCount_many': 'выбрано {{count}}',
|
||||
'reviews.bulk.selectedCount_other': 'выбрано {{count}}',
|
||||
'reviews.bulk.approveSelected': 'Approve выбранные',
|
||||
'reviews.bulk.rejectSelected': 'Reject выбранные',
|
||||
'reviews.bulk.clear': 'Снять выбор',
|
||||
'reviews.bulk.confirmApprove_one': 'Approve {{count}} draft? Это произведёт live-write для каждого.',
|
||||
'reviews.bulk.confirmApprove_few': 'Approve {{count}} draft\'а? Live-write для каждого.',
|
||||
'reviews.bulk.confirmApprove_many': 'Approve {{count}} draft\'ов? Live-write для каждого.',
|
||||
'reviews.bulk.confirmApprove_other': 'Approve {{count}} drafts? Live-write для каждого.',
|
||||
'reviews.bulk.rejectModalTitle_one': 'Reject {{count}} draft',
|
||||
'reviews.bulk.rejectModalTitle_few': 'Reject {{count}} draft\'а',
|
||||
'reviews.bulk.rejectModalTitle_many': 'Reject {{count}} draft\'ов',
|
||||
'reviews.bulk.rejectModalTitle_other': 'Reject {{count}} drafts',
|
||||
'reviews.bulk.rejectModalDescription': 'Все выбранные drafts будут отклонены с одним общим reason\'ом. Maker увидит причину в "Мои черновики".',
|
||||
'reviews.bulk.rejectReasonLabel': 'Причина (общая для всех)',
|
||||
'reviews.bulk.rejectReasonPlaceholder': 'Например: дубликат регистрации, неполные данные, не тот scope...',
|
||||
'reviews.bulk.confirmReject_one': 'Reject {{count}}',
|
||||
'reviews.bulk.confirmReject_other': 'Reject {{count}}',
|
||||
'reviews.bulk.cancel': 'Отмена',
|
||||
'reviews.bulk.resultTitle': 'Bulk операция завершена',
|
||||
'reviews.bulk.approvedCount_one': 'Approved {{count}}',
|
||||
'reviews.bulk.approvedCount_other': 'Approved {{count}}',
|
||||
'reviews.bulk.rejectedCount_one': 'Rejected {{count}}',
|
||||
'reviews.bulk.rejectedCount_other': 'Rejected {{count}}',
|
||||
'reviews.bulk.failedCount_one': 'Не прошло {{count}}',
|
||||
'reviews.bulk.failedCount_other': 'Не прошло {{count}}',
|
||||
'reviews.drawer.title': 'Согласование draft',
|
||||
'reviews.drawer.maker': 'автор',
|
||||
'reviews.drawer.submitted': 'отправлен',
|
||||
@@ -474,6 +504,30 @@ i18n
|
||||
'reviews.action.approve': 'Approve',
|
||||
'reviews.action.reject': 'Reject',
|
||||
'reviews.action.cancel': 'Cancel',
|
||||
'reviews.bulk.selectAll': 'Select all',
|
||||
'reviews.bulk.selectRow': 'Select draft',
|
||||
'reviews.bulk.selectedCount_one': '{{count}} selected',
|
||||
'reviews.bulk.selectedCount_other': '{{count}} selected',
|
||||
'reviews.bulk.approveSelected': 'Approve selected',
|
||||
'reviews.bulk.rejectSelected': 'Reject selected',
|
||||
'reviews.bulk.clear': 'Clear',
|
||||
'reviews.bulk.confirmApprove_one': 'Approve {{count}} draft? Each will produce a live-write.',
|
||||
'reviews.bulk.confirmApprove_other': 'Approve {{count}} drafts? Each will produce a live-write.',
|
||||
'reviews.bulk.rejectModalTitle_one': 'Reject {{count}} draft',
|
||||
'reviews.bulk.rejectModalTitle_other': 'Reject {{count}} drafts',
|
||||
'reviews.bulk.rejectModalDescription': 'All selected drafts will be rejected with one shared reason. Maker sees it in My drafts.',
|
||||
'reviews.bulk.rejectReasonLabel': 'Reason (shared for all)',
|
||||
'reviews.bulk.rejectReasonPlaceholder': 'e.g. duplicate registration, incomplete data, wrong scope...',
|
||||
'reviews.bulk.confirmReject_one': 'Reject {{count}}',
|
||||
'reviews.bulk.confirmReject_other': 'Reject {{count}}',
|
||||
'reviews.bulk.cancel': 'Cancel',
|
||||
'reviews.bulk.resultTitle': 'Bulk operation finished',
|
||||
'reviews.bulk.approvedCount_one': 'Approved {{count}}',
|
||||
'reviews.bulk.approvedCount_other': 'Approved {{count}}',
|
||||
'reviews.bulk.rejectedCount_one': 'Rejected {{count}}',
|
||||
'reviews.bulk.rejectedCount_other': 'Rejected {{count}}',
|
||||
'reviews.bulk.failedCount_one': '{{count}} failed',
|
||||
'reviews.bulk.failedCount_other': '{{count}} failed',
|
||||
'reviews.drawer.title': 'Review draft',
|
||||
'reviews.drawer.maker': 'maker',
|
||||
'reviews.drawer.submitted': 'submitted',
|
||||
|
||||
Reference in New Issue
Block a user