# 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-варианты:

```ts
// 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-вариантов:

```ts
// 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. Ответ

```json
{ "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

```ts
// 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
