Files
portal/docs/superpowers/specs/2026-05-12-projects-bulk-actions-design.md
T
2026-05-12 14:23:37 +03:00

16 KiB
Raw Blame History

Projects Bulk Actions — design spec

Дата: 2026-05-12 Статус: approved by Дмитрий, готов к writing-plans Контекст: /projects (ProjectsView.vue + ProjectCard.vue #817). Развитие Plan 5 frontend.

1. Цель

Расширить bulk-операции на странице «Проекты» так, чтобы пользователь мог:

  1. Выбрать группу проектов через чекбоксы на карточках (уже есть).
  2. Выбрать все проекты по текущим фильтрам через единый чекбокс над гридом (новое).
  3. Применить к выбранному набору 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 — выбрано 0
  • partial (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: delta XOR replace (нельзя оба)
  • Для 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_limit delta где у проекта delivered_today > new — попадает в skipped
    • update_limit replace с value < delivered_today для всех — 422
    • update_regions с несуществующим region_id — warning
    • scope.filter захватывает > 500 проектов — 422
    • delta И 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 — открытие, валидация, отправка payload
  • tests/Frontend/DaysBulkDialog.spec.ts — toggle weekday-кнопок, формирование bitmask
  • tests/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-filter
  • RegionsBulkDialog.story.vue, DaysBulkDialog.story.vue, LimitBulkDialog.story.vue — open state

7. Schema

Без изменений. Используются существующие колонки:

  • projects.region_mask: integer
  • projects.region_mode: text ('include'/'exclude') — не меняется bulk-ом
  • projects.delivery_days_mask: integer
  • projects.daily_limit_target: integer
  • projects.delivered_today: integer
  • projects.is_active: boolean

8. Out of scope

  • Undo bulk-операций (можно в Plan 6 если нужно)
  • Изменение region_mode bulk-ом (всегда сохраняется текущий 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