16 KiB
Projects Bulk Actions — design spec
Дата: 2026-05-12
Статус: approved by Дмитрий, готов к writing-plans
Контекст: /projects (ProjectsView.vue + ProjectCard.vue #817). Развитие Plan 5 frontend.
1. Цель
Расширить bulk-операции на странице «Проекты» так, чтобы пользователь мог:
- Выбрать группу проектов через чекбоксы на карточках (уже есть).
- Выбрать все проекты по текущим фильтрам через единый чекбокс над гридом (новое).
- Применить к выбранному набору 4 категории изменений:
- Регионы — добавить и/или убрать
- Дни сбора лидов — добавить и/или убрать
- Лимит лидов в день — изменить (delta или replace)
- Сбор лидов вкл/выкл (pause/resume) — уже есть, остаётся
Существующие bulk-действия (pause/resume/archive) сохраняются — расширяется набор операций и UX.
2. Текущее состояние
| Артефакт | Что есть | Что меняется |
|---|---|---|
ProjectCard.vue |
v-checkbox с emit toggle-select (#817 — обёртка v-card-item) |
Без изменений |
ProjectsView.vue |
Грид карточек + BulkActionsBar при selectedIds.size > 0 |
+ toolbar с select-all чекбоксом и счётчиком |
BulkActionsBar.vue |
3 кнопки: Приостановить / Возобновить / Архивировать | + 3 кнопки-диалога: Регионы / Дни сбора / Лимит лидов; визуальная группировка по 4 категориям |
projectsStore.ts |
selectedIds, toggleSelect, clearSelection, bulkAction(pause/resume/archive) |
+ selectAllByFilter: boolean, bulkUpdate(payload) |
ProjectController::bulk |
BulkProjectActionRequest: action ∈ pause/resume/archive, ids: [int, max 100] |
Расширяется discriminator + payload, scope (ids OR filter) |
ProjectService::bulkAction |
Простой Project::whereIn->update(['is_active' => ...]) |
Расширяется: bitmask-операции, delta для лимита, валидация |
3. UX
3.1. Toolbar над гридом
Появляется НАД гридом карточек, ниже фильтров (Тип / Статус / Поиск):
[☐] Выбрать все по фильтрам · Выбрано: 0 из 47 (по текущим фильтрам)
Состояния чекбокса:
none— выбрано 0partial(indeterminate) — выбрана часть из видимыхall— выбраны все из текущего набора по фильтрам
Клик при none → выбрать все по фильтрам (selectAllByFilter = true, selectedIds обновляется списком ID для отображения чекбоксов на видимых карточках).
Клик при partial или all → снять выбор (clearSelection).
При смене фильтров — выбор сбрасывается.
3.2. BulkActionsBar (расширенный)
Появляется снизу при selectedIds.size > 0 или selectAllByFilter. Структура:
[Выбрано: 5] | 1. Регионы: [Регионы…] | 2. Дни: [Дни сбора…] | 3. Лимит: [Лимит лидов…] | 4. Сбор: [Выключить] [Включить] | [Архивировать] [Снять выбор]
Категории 1-3 открывают диалог Add/Remove (см. §3.3).
Категория 4 — два инстант-кнопки без диалога (action: pause/resume).
«Архивировать» — с window.confirm (как сейчас).
3.3. Диалоги Add/Remove
Единый паттерн: одна форма, две секции — «Добавить» (зелёная) и «Убрать» (красная).
RegionsBulkDialog:
- Два multi-select с региональными chips.
- Конфликт «один регион в обеих секциях» — Remove побеждает (валидация на клиенте предупреждает).
- Превью: «У 5 проектов будет добавлено: А, Б. Убрано: В.» (без диффа per-project — слишком шумно).
DaysBulkDialog:
- Две группы из 7 weekday-кнопок (Пн-Вс). Toggle-state.
- В секции «Добавить» выбраны дни для OR-маски. В «Убрать» — для AND-NOT.
- Превью: «У 5 проектов добавлены: Вт, Чт. Убраны: Сб, Вс.»
LimitBulkDialog:
- Два числовых input'а: «➕ Прибавить к лимиту» и «➖ Убавить лимит» (можно один, можно оба).
- Чекбокс «Заменить на абсолютное значение» — переключает в режим replace с одним числовым input'ом.
- Превью: «У 5 проектов лимит изменится на +100 (новые значения от 300 до 500).»
Все три диалога — <v-dialog>. Кнопки «Отмена» / «Применить ко всем N».
3.4. Пустые состояния
- Если ни Add, ни Remove не заполнено в диалоге — кнопка «Применить» disabled.
- Если выбрано 0 проектов — BulkActionsBar скрыт.
4. Backend API
4.1. Расширение POST /api/projects/bulk
Discriminator по action. Payload-варианты:
// 1. Instant actions (без диалога) — как сейчас
{ action: 'pause' | 'resume' | 'archive', ids: number[] }
// 2. Regions: Add/Remove (region_mask bitmask с region_mode)
{
action: 'update_regions',
ids: number[],
add?: number[], // массив region_id, превращается в bitmask на сервере
remove?: number[],
}
// 3. Days: Add/Remove (delivery_days_mask bitmask Пн=1..Вс=64)
{
action: 'update_days',
ids: number[],
add?: number, // bitmask 0..127
remove?: number,
}
// 4. Limit: delta ИЛИ replace (взаимоисключающие)
{
action: 'update_limit',
ids: number[],
delta?: number, // целое (отрицательное = убавить)
replace?: number, // абсолютное значение
}
4.2. Scope: ids vs filter
Любой action может приниматься в одном из двух scope-вариантов:
// 1. По списку id (ручной выбор)
{ ..., ids: [3, 7, 12] }
// 2. По фильтрам (select-all)
{
...,
scope: {
filter: {
signal_type?: string,
status?: 'active' | 'paused' | 'archived',
search?: string,
},
},
}
Сервис резолвит filter → list of ids внутри транзакции (snapshot in time). Лимит MAX 500 проектов за один bulk-вызов (защита от runaway updates); если фильтр захватывает больше — 422 «Уточните фильтры или выберите вручную».
4.3. Ответ
{ "updated": 5, "skipped": [], "warnings": [] }
skipped — массив { id, reason } если какие-то проекты не обновились (например, конфликт лимита с delivered_today).
warnings — массив сообщений (например, «Регион XYZ не найден, пропущен»).
При фатальной ошибке валидации (несовпадение типов, > 500 проектов, конфликт всего лимита) — 422 без частичного применения.
4.4. Валидация (FormRequest)
BulkProjectActionRequest расширяется:
action∈ enum (5 значений: pause/resume/archive/update_regions/update_days/update_limit)ids/scope.filter— хотя бы одно из двух обязательно- Для каждого action — свой
prepareForValidation+ правила по полям payload'а - Для
update_limit:deltaXORreplace(нельзя оба) - Для
update_days:add | remove≤ 127, оба ≥ 0 - Для
update_regions: id из массивов существуют вregions(single query)
4.5. Семантика на БД
Внутри транзакции:
- regions:
region_mask = (region_mask | computed_add_mask) & ~computed_remove_mask— применяется ко всем выбранным без skip. - days:
delivery_days_mask = (delivery_days_mask | :add) & ~:remove— применяется ко всем выбранным без skip. - limit delta: для каждого проекта вычисляется
new_value = daily_limit_target + :delta; еслиnew_value < delivered_today, проект попадает вskippedсreason: 'below_delivered_today'. Иначеdaily_limit_target = new_value. - limit replace: для каждого проекта если
:value < delivered_today→ skip с тем же reason. Иначеdaily_limit_target = :value.
Реализация — Project::whereIn(...)->update(...) для regions/days (атомарный bitmask-апдейт); для limit — выборка с проверкой, разделение на updatable/skipped, затем whereIn(updatable_ids)->update(...).
region_mode — не меняется bulk-операцией (остаётся как было). Add/remove применяются к region_mask независимо от текущего region_mode проекта; интерпретация маски (include vs exclude) сохраняется согласно сохранённому region_mode.
5. Frontend store
// projectsStore.ts добавления
const selectAllByFilter = ref<boolean>(false);
function toggleSelectAllByFilter() {
if (selectAllByFilter.value || selectedIds.value.size > 0) {
clearSelection();
selectAllByFilter.value = false;
} else {
selectAllByFilter.value = true;
// Заполняем selectedIds id'ами текущей страницы для отображения чекбоксов
items.value.forEach(p => selectedIds.value.add(p.id));
}
}
async function bulkUpdate(payload: BulkPayload): Promise<BulkResponse> {
const body = selectAllByFilter.value
? { ...payload, scope: { filter: serializeFilters() } }
: { ...payload, ids: Array.from(selectedIds.value) };
const { data } = await axios.post('/api/projects/bulk', body);
clearSelection();
selectAllByFilter.value = false;
await fetch();
return data;
}
// При смене фильтров — сброс
watch(() => [filters.signal_type, filters.status, filters.search], () => {
clearSelection();
selectAllByFilter.value = false;
});
Существующий bulkAction(action) остаётся для pause/resume/archive (тонкая обёртка над bulkUpdate({ action, ...})).
6. Тестирование
Pest (backend)
Новый файл tests/Feature/Api/ProjectBulkActionsTest.php:
- Smoke per action: 5 actions × 2 scope-варианта (ids/filter) = 10 happy-cases
- Edge:
update_limitdelta где у проекта delivered_today > new — попадает в skippedupdate_limitreplace с value < delivered_today для всех — 422update_regionsс несуществующим region_id — warningscope.filterзахватывает > 500 проектов — 422deltaИreplaceодновременно — 422- Пустой
idsИ пустойscope— 422
- RLS: bulk не пересекает tenant_id (FactoryTraits + assertGuardedByRls helper)
Vitest (frontend)
Новые spec'и:
tests/Frontend/ProjectsToolbar.spec.ts— 3 состояния select-all (none/partial/all), сброс на смену фильтровtests/Frontend/RegionsBulkDialog.spec.ts— открытие, валидация, отправка payloadtests/Frontend/DaysBulkDialog.spec.ts— toggle weekday-кнопок, формирование bitmasktests/Frontend/LimitBulkDialog.spec.ts— delta vs replace toggle, валидацияtests/Frontend/projectsStore.bulkUpdate.spec.ts— scope-discriminator (ids vs filter)
Расширить tests/Frontend/BulkActionsBar.spec.ts: проверка появления 3 новых кнопок, открытие диалогов.
Histoire
BulkActionsBar.story.vue— расширить вариантами:selected=0(скрыт),=1,=5,=all-by-filterRegionsBulkDialog.story.vue,DaysBulkDialog.story.vue,LimitBulkDialog.story.vue— open state
7. Schema
Без изменений. Используются существующие колонки:
projects.region_mask: integerprojects.region_mode: text('include'/'exclude') — не меняется bulk-омprojects.delivery_days_mask: integerprojects.daily_limit_target: integerprojects.delivered_today: integerprojects.is_active: boolean
8. Out of scope
- Undo bulk-операций (можно в Plan 6 если нужно)
- Изменение
region_modebulk-ом (всегда сохраняется текущий mode проекта) - Hard-delete архивных проектов через bulk (есть только archive)
- Импорт/экспорт настроек проектов
- Bulk-операции по статусам отличным от текущих 4 категорий (signal_type, sms_senders, и т.п.)
9. Открытые вопросы (на будущее)
- Q-1: Audit log bulk-операций — нужно ли логировать в
audit_logsкаждое изменение per-project, или хватит одной строки на bulk? Решение: одна строка сaffected_ids, для MVP. - Q-2: Уведомления тенанта при bulk-pause всех проектов (потенциальная потеря лидов) — добавлять
ZeroBalancePausedMail-style? Не в этом скоупе.
10. Acceptance criteria
- Toolbar с select-all чекбоксом виден над гридом, корректно показывает 3 состояния
- BulkActionsBar показывает все 4 категории + Архив + Снять выбор
- 3 диалога (Regions/Days/Limit) открываются, валидируют, отправляют корректный payload
- Pause/Resume инстант-кнопки работают без диалога
- При смене фильтров — выбор сбрасывается
- Backend принимает оба scope (ids/filter), отдаёт
{updated, skipped, warnings} - Лимит ниже
delivered_todayпопадает вskipped, не блокирует остальных - > 500 проектов по filter → 422 с message «Уточните фильтры»
- Pest 100% pass, Vitest 100% pass, Histoire stories рендерятся
- RLS: bulk не пересекает tenant