# Spec: Plan 5 — Frontend Project UI + Backend CRUD

**Версия:** 1.0
**Дата:** 11.05.2026
**Автор:** Claude Code (skill: superpowers:brainstorming)
**Заказчик:** Дмитрий
**Базовый HEAD:** `4bc488e` (origin/main, Plan 4 закрыт + display-fix)
**Источники:**

- [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](2026-05-10-supplier-integration-design.md) — общий дизайн интеграции с supplier portal (Plan 2/3/4 база)
- [db/schema.sql](../../db/schema.sql) v8.19 — `projects`, `supplier_projects`
- [docs/CRM_bp-gr_Инструкция_v8_5.md](../../CRM_bp-gr_Инструкция_v8_5.md) — продуктовое ТЗ, §6 (проекты)

## 1. Контекст

После Plans 1-4 у Лидерры есть:

- Schema `projects` с полным набором полей (signal_type, signal_identifier, sms_senders/keyword, daily_limit_target, supplier_b1/b2/b3_project_id, region_mask, delivery_days_mask, assignment_strategy, ttfr_target_minutes).
- Schema `supplier_projects` (sync_status, last_synced_at).
- Backend supplier-sync инфраструктура (Plan 3): `SupplierPortalClient`, `SupplierWebhookController`, `RouteSupplierLeadJob`.
- `ProjectController@index` — **только GET-list-stub** (4 поля: id/name/tag/type) для NewDealDialog dropdown'а.

Чего **нет**:

- Backend: full CRUD на `projects` (POST/PATCH/DELETE/sync-trigger/bulk/toggle-active).
- Frontend: **ProjectsView с нуля**. Нет route `/projects`, нет пункта в nav-tree, нет компонентов.

Tenant Лидерры не может управлять своими источниками лидов через UI. Plan 5 закрывает этот разрыв.

## 2. Scope и non-goals

### В scope

- Backend CRUD: GET list/show, POST/PATCH/DELETE, sync-trigger, toggle-active, bulk operations
- Frontend ProjectsView: карточки с прогресс-баром «доставлено/лимит сегодня», фильтры, поиск
- NewProjectDialog: 3 таба (Сайт / Звонок / СМС) + общие поля (name, daily_limit, regions, workdays)
- EditProjectDialog: re-use NewProjectDialog в режиме edit (одна форма для create + edit)
- Async sync flow: SyncSupplierProjectJob + polling sync_status каждые 5 сек
- Schema delta v8.19 → v8.20: `projects.archived_at TIMESTAMPTZ NULL`
- Bulk operations: pause/resume/archive нескольких проектов через один endpoint
- Pause/Resume toggle (быстрый toggle прямо на карточке)
- Manual «Sync now» кнопка (для retry после sync_status='failed')

### Не в scope

- **SSE/WebSocket** для sync-статусов — polling 5 сек достаточно для MVP (5-50 проектов на tenant).
- **Карта РФ** для выбора регионов — chip-multiselect с поиском (см. §6.4).
- **Hard delete** — только soft (`is_active=false` + `archived_at`). Schema RESTRICT FK от deals на projects, hard delete сломал бы историю.
- **Project templates** (создание из шаблона) — Plan 6+.
- **Шаблонизация sms_senders** (один sender для нескольких проектов) — поведение supplier portal на B3 сделает это автоматически (Plan 3 базис).
- **Project analytics** (history, conversion stats) — задача ReportsView, Plan 6+.
- **Manual sender-conflict resolution UI** — если sender уже занят, пользователь видит ошибку, должен выбрать другой. Auto-merge на B3 — следующий план.

### Допущения

- Tenant Лидерры лимитирован `tenants.limits.max_projects` (по умолчанию 10 для тарифа «Команда»).
- `delivered_today` / `delivered_in_month` обновляются существующим RouteSupplierLeadJob — Plan 5 их не трогает, только читает.
- На MVP RLS изоляция: `auth()->user()->tenant_id` устанавливается в `app.current_tenant_id` через middleware `tenant`.

## 3. Архитектура

### Backend компоненты

```
app/Http/Controllers/Api/ProjectController.php   ← расширение (~250 строк)
  - index()       GET    /api/projects
  - show($id)     GET    /api/projects/{id}
  - store()       POST   /api/projects
  - update($id)   PATCH  /api/projects/{id}
  - destroy($id)  DELETE /api/projects/{id}
  - sync($id)     POST   /api/projects/{id}/sync
  - toggle($id)   PATCH  /api/projects/{id}/toggle-active
  - bulk()        POST   /api/projects/bulk

app/Http/Requests/StoreProjectRequest.php        ← новый
app/Http/Requests/UpdateProjectRequest.php       ← новый
app/Http/Requests/BulkProjectActionRequest.php   ← новый

app/Services/Project/ProjectService.php          ← новый
  - create(array $data): Project
  - update(Project $p, array $data): Project
  - archive(Project $p): void
  - triggerSync(Project $p): void
  - bulkAction(string $action, array $ids): int

app/Jobs/SyncSupplierProjectJob.php              ← новый (Plan 3 уже имеет похожие jobs)
  - handle(): резолвит платформы, дёргает SupplierPortalClient,
              привязывает supplier_projects, выставляет sync_status

database/migrations/2026_05_..._add_archived_at_to_projects.php
  - ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ NULL
```

### Frontend компоненты

```
resources/js/router/index.ts                      ← +route /projects
resources/js/components/layout/AppLayout.vue      ← +пункт «Проекты» в nav-tree

resources/js/views/ProjectsView.vue               ← новый (~200 строк)
  - header: title, фильтры (signal_type/status), search input, кнопка «+ Создать»
  - body: grid карточек ProjectCard + BulkActionsBar (если selected.length > 0)
  - state: pagination, фильтры, выбранные ids

resources/js/views/projects/NewProjectDialog.vue  ← новый (~250 строк)
  - <v-tabs>: Сайт / Звонок / СМС
  - под каждым табом — соответствующие поля + общие
  - validation через VeeValidate или нативный (на выбор имплементатора)

resources/js/views/projects/EditProjectDialog.vue ← новый (~30 строк)
  - re-use NewProjectDialog с :initial-data и :mode="edit"
  - signal_type readonly после создания (нельзя поменять)

resources/js/components/projects/ProjectCard.vue  ← новый (~120 строк)
  - имя, type-chip, identifier, прогресс-бар (delivered_today / daily_limit_target)
  - sync-status indicator (color-coded по 4 значениям)
  - меню действий ⋮: Edit, Pause/Resume toggle, Sync now, Archive
  - чекбокс для bulk

resources/js/components/projects/BulkActionsBar.vue ← новый (~80 строк)
  - toolbar fixed-bottom с кнопками: Pause, Resume, Archive
  - confirm-dialog перед action

resources/js/stores/projectsStore.ts              ← новый (~150 строк)
  - state: items, filters, pagination, selectedIds, pendingIds (Set)
  - actions: fetch, create, update, archive, sync, toggle, bulkAction
  - polling: пока pendingIds.size > 0, refetch по этим ids каждые 5 сек
  - cleanup polling на unmount

resources/js/views/ProjectsView.story.vue         ← новый (Histoire)
resources/js/views/projects/NewProjectDialog.story.vue
resources/js/components/projects/ProjectCard.story.vue
resources/js/components/projects/BulkActionsBar.story.vue
```

## 4. Data model

### 4.1. Schema delta v8.19 → v8.20

```sql
ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ NULL;
```

**Зачем колонка отдельно от `is_active`:**

- `is_active=false` означает «приостановлен» — обратимо, статус виден в UI как «На паузе».
- `archived_at IS NOT NULL` — «архивирован», не отображается в default-list, требует `?show=archived` для просмотра. Restore возможен через POST `/api/projects/{id}/restore` (отдельный endpoint, Plan 6 если нужен).

В Plan 5 restore-endpoint **не реализуется** — архивный проект восстанавливается только через прямой SQL или admin SaaS UI (out-of-scope).

### 4.2. Использование существующих полей `projects`

| Поле | Использование в Plan 5 |
|---|---|
| `id`, `tenant_id`, `name` | Базовое |
| `signal_type` | enum: `site` / `call` / `sms` (зашит в валидации) |
| `signal_identifier` | Для site/call: домен или 11-значный номер с 7 |
| `sms_senders` (jsonb) | Для sms: `array<string max:11>` |
| `sms_keyword` (text) | Для sms: nullable, при пустом — только B3 |
| `daily_limit_target` | Целевой лимит в день |
| `effective_daily_limit_today` | **Read-only в UI** (вычисляется существующим backend'ом на основании balance) |
| `delivered_today`, `delivered_in_month` | **Read-only** (обновляется RouteSupplierLeadJob) |
| `supplier_b1/b2/b3_project_id` | **Read-only** (обновляется SyncSupplierProjectJob) |
| `is_active` | pause/resume toggle |
| `region_mask` | int (bitmask 89 регионов; 0 = вся РФ) |
| `region_mode` | string: `include` / `exclude` (значения зашиты в CHECK-constraint schema.sql v8.20). В Plan 5 используем только `include` (default; пустой `region_mask=0` = все РФ; ненулевой `region_mask` = whitelist выбранных регионов). `exclude` (blacklist-семантика) остаётся в schema на будущее (Plan 6+), сейчас в UI недоступен. **Note:** изначально spec/plan использовали `all`/`whitelist` semantic-naming, но при имплементации Task 3 обнаружено что schema CHECK требует `include`/`exclude` — schema = источник истины, поэтому propagate во все Task'и.|
| `delivery_days_mask` | int 1-127 (bitmask 7 дней; например 31 = пн-пт) |
| `assignment_strategy` | string (по умолчанию `round-robin`) — Plan 5 не редактирует, дефолт |
| `ttfr_target_minutes` | int — Plan 5 не редактирует, дефолт из schema |
| `archived_at` | **новое**: timestamp архивирования |

### 4.3. Связь с `supplier_projects`

В Plan 5 при создании/редактировании Лидерра-проекта `SyncSupplierProjectJob`:

1. Резолвит набор платформ:
   - site/call → B1 + B2 + B3
   - sms с keyword → B2 + B3
   - sms без keyword → только B3
2. На каждой платформе ищет existing `supplier_projects` по `unique_key`:
   - site: домен
   - call: номер
   - sms B2: `<senders[0]>+<keyword>`
   - sms B3: `<senders[0]>` (если sms_senders.length > 1 — несколько supplier_projects)
3. Если не нашёл — создаёт через SupplierPortalClient → INSERT в `supplier_projects`.
4. Записывает foreign keys в `projects.supplier_b1_project_id|b2|b3`.
5. Выставляет `supplier_projects.sync_status='ok'` (или `'failed'` с `last_error`).

UI читает `sync_status` через `GET /api/projects/{id}` (включая joined supplier_projects).

## 5. API контракт

### 5.1. GET /api/projects

**Запрос:**

```
GET /api/projects?signal_type=site&status=active&search=окна&page=1&per_page=20
```

**Query parameters:**

| Параметр | Тип | Значение |
|---|---|---|
| `signal_type` | string | `site` / `call` / `sms` (фильтр; пусто = все) |
| `status` | string | `active` / `paused` / `archived` (default — `active`+`paused`, исключая archived) |
| `search` | string | LIKE-поиск по `name` + `signal_identifier` (debounce 300ms на фронте) |
| `page`, `per_page` | int | пагинация (default 1 / 20; max per_page=100) |
| `ids` | csv-string | batch-fetch для polling: `?ids=1,5,7` — возвращает только эти ids (без пагинации, без фильтров) |

**Ответ 200:**

```json
{
  "data": [
    {
      "id": 1,
      "name": "Окна СПб",
      "signal_type": "site",
      "signal_identifier": "okna-spb.ru",
      "sms_senders": null,
      "sms_keyword": null,
      "daily_limit_target": 50,
      "effective_daily_limit_today": 50,
      "delivered_today": 32,
      "delivered_in_month": 412,
      "is_active": true,
      "archived_at": null,
      "region_mask": 0,
      "region_mode": "include",
      "delivery_days_mask": 127,
      "sync_status": "ok",
      "last_synced_at": "2026-05-11T13:30:00Z"
    }
  ],
  "meta": { "current_page": 1, "per_page": 20, "total": 12 }
}
```

`sync_status` — агрегированный (worst-of по supplier_projects): если хоть один `failed` → `failed`; иначе если хоть один `pending` → `pending`; иначе `ok`.

### 5.2. GET /api/projects/{id}

Включает массив `supplier_links` с детализацией:

```json
{
  "data": {
    "...": "поля как в index",
    "supplier_links": [
      { "platform": "b1", "supplier_project_id": 42, "sync_status": "ok", "last_synced_at": "..." },
      { "platform": "b2", "supplier_project_id": 43, "sync_status": "ok", "last_synced_at": "..." },
      { "platform": "b3", "supplier_project_id": 44, "sync_status": "ok", "last_synced_at": "..." }
    ]
  }
}
```

### 5.3. POST /api/projects

**Тело (site):**

```json
{
  "name": "Окна СПб",
  "signal_type": "site",
  "signal_identifier": "okna-spb.ru",
  "daily_limit_target": 50,
  "region_mask": 0,
  "region_mode": "include",
  "delivery_days_mask": 127
}
```

**Тело (sms с keyword):**

```json
{
  "name": "Ипотека Тинькофф",
  "signal_type": "sms",
  "sms_senders": ["TINKOFF"],
  "sms_keyword": "ипотека",
  "daily_limit_target": 100,
  "region_mask": 0,
  "region_mode": "include",
  "delivery_days_mask": 127
}
```

**Ответ 201:**

```json
{
  "data": { "id": 13, "...": "...", "sync_status": "pending" }
}
```

После 201 Pinia store добавляет `13` в `pendingIds` → polling запускается.

### 5.4. PATCH /api/projects/{id}

Partial body — любое подмножество полей кроме `signal_type` (immutable после create), `tenant_id` (immutable), `delivered_*` / `supplier_b*_project_id` (read-only).

Если изменился `signal_identifier` / `sms_senders` / `sms_keyword` — диспатчится `SyncSupplierProjectJob` (sync_status переходит в pending).

### 5.5. DELETE /api/projects/{id}

Soft delete:

```
UPDATE projects SET is_active=false, archived_at=NOW() WHERE id=:id AND tenant_id=current_tenant_id();
```

Возвращает **204 No Content**. SyncSupplierProjectJob НЕ диспатчится — supplier_projects остаются как есть (могут шарить другие Лидерра-проекты). Если supplier_projects больше никем не используются — это уберёт отдельный `CleanupOrphanSupplierProjectsJob` (out-of-scope Plan 5).

### 5.6. POST /api/projects/{id}/sync

Re-dispatch sync job. Возвращает:

```json
{ "queued": true, "sync_status": "pending" }
```

Status 202 Accepted. UI добавляет id в pendingIds → polling.

### 5.7. PATCH /api/projects/{id}/toggle-active

```json
{ "is_active": false }
```

Не диспатчит sync job. Возвращает 200 + project. UI обновляет state.

### 5.8. POST /api/projects/bulk

```json
{ "action": "pause", "ids": [1, 5, 7] }
```

`action` ∈ `{"pause", "resume", "archive"}`. Возвращает:

```json
{ "updated": 3 }
```

Все ids фильтруются по tenant_id (RLS) — если в массиве чужие — они молча отбрасываются (см. §7).

## 6. UI/UX детали

### 6.1. Layout списка (карточки)

- Vue 3 + Vuetify 3, palette Forest (`#0F6E56` primary)
- Grid responsive: 1 колонка мобильно, 2 — tablet, 3 — desktop
- ProjectCard:
  - Header: name (bold) + type-chip (по цветам) + `⋮` action menu
  - Sub: signal_identifier (или `<senders>·<keyword>` для sms) — text-medium-emphasis
  - Прогресс: `delivered_today / daily_limit_target` + horizontal `v-progress-linear` (Forest при OK, серый при paused, красный при failed)
  - Footer: sync-status indicator с цветом и текстом
- Архивные не показываются по умолчанию; фильтр включает их по `?show=archived`

### 6.2. NewProjectDialog (3 таба)

- `<v-tabs>` сверху: Сайт / Звонок / СМС (с иконками)
- **Общие поля** размещаются **под `<v-tabs>` в стационарной секции** (не внутри `<v-tab-item>`-ов): `name`, `daily_limit_target`, `regions`, `workdays`. Так они не «плывут» при переключении таба.
- Поля по табу (внутри `<v-tabs-window-item>` соответствующего signal_type):
  - **Сайт**: signal_identifier (label «Домен конкурента», placeholder «okna-konkurent.ru», hint про формат)
  - **Звонок**: signal_identifier (label «Номер конкурента», маска `+7 (XXX) XXX-XX-XX`, валидация 11-значный)
  - **СМС**: sms_senders (chip-input до 11 символов, multiple), sms_keyword (optional, hint про B2/B3)

Кнопки внизу: «Отмена» / «Создать». При submit — POST, success → close dialog + add to pendingIds.

### 6.3. EditProjectDialog

Тот же компонент с `:mode="edit"`. Различия:

- `<v-tabs>` disabled (signal_type не меняется)
- signal_identifier readonly для site/call
- sms_senders / sms_keyword редактируется (но триггерит resync)
- name / daily_limit / regions / workdays — редактируется свободно

### 6.4. Регионы

- `<v-autocomplete multiple chips clearable>` с поиском
- Items — массив 89 регионов РФ из локального constants (`resources/js/constants/regions.ts`)
- Каждый регион = `{ code: int, name: string }` (например `{ code: 78, name: 'Санкт-Петербург' }`)
- Пусто = вся РФ (region_mask=0, region_mode='include')
- Выбраны — chip'ы; bit-mask вычисляется на фронте перед submit

### 6.5. Workdays

- 7 toggle-buttons (Пн Вт Ср Чт Пт Сб Вс)
- Shortcut buttons: «Будни» (1-5), «Все дни» (1-7), «Только выходные» (6-7)
- bit-mask: bit0=Пн, ..., bit6=Вс

### 6.6. Sync-status indicator

| Status | Цвет | Текст |
|---|---|---|
| `ok` | `#0F6E56` (Forest primary) | «● Sync OK» |
| `pending` | `#FF9800` (orange) | «● Sync pending» + small spinner |
| `failed` | `#D32F2F` (error red) | «● Sync failed» + tooltip с `last_error` |
| paused (is_active=false) | `#999` (grey) | «● На паузе» |

### 6.7. Polling-логика

```typescript
// projectsStore.ts (псевдокод)
const pendingIds = ref<Set<number>>(new Set());
let pollTimeout: number | null = null;
let currentDelay = 5000; // ms; растёт при ошибках до 30000 макс
const DELAY_OK = 5000;
const DELAY_MAX = 30000;

function startPolling() {
  if (pollTimeout) return;
  scheduleNext();
}

function scheduleNext() {
  pollTimeout = setTimeout(async () => {
    if (pendingIds.value.size === 0) {
      stopPolling();
      return;
    }
    try {
      const ids = Array.from(pendingIds.value);
      const { data } = await axios.get('/api/projects', { params: { ids: ids.join(',') } });
      for (const project of data.data) {
        updateItem(project);
        if (project.sync_status === 'ok' || project.sync_status === 'failed') {
          pendingIds.value.delete(project.id);
        }
      }
      currentDelay = DELAY_OK; // reset на success
    } catch (e) {
      currentDelay = Math.min(currentDelay * 2, DELAY_MAX); // exponential backoff
      // если currentDelay уже на потолке после 3-й ошибки подряд — show snackbar и стоп
    }
    scheduleNext();
  }, currentDelay);
}

function stopPolling() {
  if (pollTimeout) clearTimeout(pollTimeout);
  pollTimeout = null;
  currentDelay = DELAY_OK;
}
```

Используем `setTimeout`-recursion (не `setInterval`) чтобы переменный delay при backoff'е работал корректно.

GET endpoint должен поддерживать `?ids=1,5,7` для batch-fetch (расширение в §5.1).

## 7. Validation rules

### 7.1. StoreProjectRequest

```php
public function rules(): array
{
    $signalType = $this->input('signal_type');

    $rules = [
        'name' => ['required', 'string', 'max:255'],
        'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])],
        'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
        'region_mask' => ['required', 'integer', 'min:0'],
        'region_mode' => ['required', Rule::in(['include', 'exclude'])],
        'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
    ];

    if ($signalType === 'site') {
        $rules['signal_identifier'] = ['required', 'string', 'regex:/^[a-z0-9.-]+\.[a-z]{2,}$/i'];
    }
    if ($signalType === 'call') {
        $rules['signal_identifier'] = ['required', 'string', 'regex:/^7\d{10}$/'];
    }
    if ($signalType === 'sms') {
        $rules['sms_senders'] = ['required', 'array', 'min:1'];
        $rules['sms_senders.*'] = ['string', 'max:11'];
        $rules['sms_keyword'] = ['nullable', 'string', 'max:50'];
    }

    return $rules;
}
```

### 7.2. UpdateProjectRequest

Тот же набор кроме `signal_type` (immutable) и `signal_identifier` для site/call (immutable; для sms `sms_senders/keyword` редактируется).

### 7.3. BulkProjectActionRequest

```php
public function rules(): array
{
    return [
        'action' => ['required', Rule::in(['pause', 'resume', 'archive'])],
        'ids' => ['required', 'array', 'min:1', 'max:100'],
        'ids.*' => ['integer', 'min:1'],
    ];
}
```

Max 100 ids — защита от DoS-payload'ов.

## 8. Обработка ошибок

| Место падения | Что может упасть | Поведение |
|---|---|---|
| `StoreProjectRequest` | Невалидный домен / телефон / senders | 422 + payload `{errors: {field: [msg]}}` (стандарт Laravel). UI рендерит ошибки в `<v-text-field error-messages>`. |
| `ProjectService::create` | Лимит `tenants.limits.max_projects` исчерпан | 403 `{message: "Достигнут лимит проектов (X) тарифа «Y»"}`. UI: alert с ссылкой на /billing «Смените тариф». |
| `SyncSupplierProjectJob` | Supplier portal недоступен | sync_status='failed', `last_error="Не удалось подключиться к порталу поставщика"`. Retry 3 раза с backoff 15с/60с/300с. После 3-го — финальный fail. UI отображает кнопку «Sync now» для retry. |
| `SyncSupplierProjectJob` | Sender уже занят (B3 не разрешает дубликаты) | sync_status='failed', `last_error="Sender '<X>' уже зарегистрирован на платформе B3 — выберите другой"`. UI отображает текст из tooltip'а. |
| `ProjectService::update` | daily_limit_target < delivered_today | 422 `{errors: {daily_limit_target: ["Лимит не может быть меньше уже доставленных лидов сегодня (X)"]}}`. |
| `ProjectService::archive` | Project уже архивирован | 409 Conflict `{message: "Project уже архивирован"}`. |
| `bulk` action | Некоторые ids чужие (другой tenant) | RLS отфильтрует через `WHERE tenant_id=current_tenant_id()`. Чужие ids молча игнорируются. Ответ `{updated: N}` где N = только свои. |
| Polling 5xx | Backend временно недоступен | Frontend exponential backoff: 5с → 10с → 30с → стоп с `v-snackbar` «Не удалось проверить статус. Обновите страницу.». |

## 9. Тестирование

### 9.1. Pest (Feature, integration)

```
tests/Feature/Projects/
├── ProjectsCrudTest.php             # index/show/store/update/destroy happy path
├── ProjectsValidationTest.php       # все 422-ветки (3 signal_type × invalid поля)
├── ProjectsTenantIsolationTest.php  # RLS: tenant X не видит проекты tenant Y
├── ProjectsSyncTriggerTest.php      # POST /sync → job dispatched, status pending
├── ProjectsToggleActiveTest.php     # PATCH /toggle-active
├── ProjectsBulkTest.php             # bulk pause/resume/archive + mixed-tenant отфильтровывается
├── ProjectsLimitTest.php            # max_projects превышен → 403
└── ProjectsArchiveTest.php          # destroy → is_active=false, archived_at != null

tests/Feature/Jobs/
└── SyncSupplierProjectJobTest.php   # happy + 3 failure scenarios (timeout/conflict/partial)
```

### 9.2. Vitest (unit-frontend)

```
tests/Frontend/
├── ProjectsView.spec.ts             # рендер, фильтры, search-debounce, empty state
├── NewProjectDialog.spec.ts         # переключение табов, валидация, submit
├── EditProjectDialog.spec.ts        # readonly-поля, prefill initial-data
├── ProjectCard.spec.ts              # sync-status цвета, прогресс-бар, меню действий
├── BulkActionsBar.spec.ts           # selection-flow, confirm-dialog
└── projectsStore.spec.ts            # polling (mock setInterval / fake timers)
```

### 9.3. Histoire (visual regression baseline)

```
resources/js/views/ProjectsView.story.vue                  # empty/5 projects/50 projects
resources/js/views/projects/NewProjectDialog.story.vue     # 3 tabs × 3 states (valid/error/loading)
resources/js/views/projects/EditProjectDialog.story.vue    # site/call/sms readonly states
resources/js/components/projects/ProjectCard.story.vue     # ok/pending/failed/paused
resources/js/components/projects/BulkActionsBar.story.vue  # 1/3/many selected
```

### 9.4. TDD-цикл

Каждая task = vertical-slice: failing test → minimal code → green → refactor → commit. По паттерну Plan 4.

## 10. Acceptance criteria

- **AC-1.** `GET /api/projects` возвращает paginated list текущего tenant'а с фильтрами по signal_type, status, search.
- **AC-2.** `POST /api/projects` для site/call/sms создаёт запись + диспатчит SyncSupplierProjectJob (sync_status='pending').
- **AC-3.** `PATCH /api/projects/{id}` обновляет разрешённые поля, не трогает immutable (signal_type, supplier_b*_project_id).
- **AC-4.** `DELETE /api/projects/{id}` ставит `is_active=false, archived_at=NOW()`.
- **AC-5.** `POST /api/projects/{id}/sync` re-dispatch'ит job.
- **AC-6.** `PATCH /api/projects/{id}/toggle-active` переключает is_active без resync.
- **AC-7.** `POST /api/projects/bulk` атомарно выполняет action на до 100 ids, чужие отфильтровывает.
- **AC-8.** ProjectsView отображает карточки с прогресс-баром и sync-status indicator.
- **AC-9.** NewProjectDialog имеет 3 таба (Сайт/Звонок/СМС) с правильной валидацией по типу.
- **AC-10.** EditProjectDialog re-use NewProjectDialog с readonly signal_type.
- **AC-11.** Polling каждые 5 сек обновляет sync_status pending → ok/failed, останавливается когда pendingIds пуст.
- **AC-12.** Bulk-toolbar появляется при selected.length>0, confirm-dialog перед действием.
- **AC-13.** Все 422-ошибки валидации рендерятся в `<v-text-field error-messages>`.
- **AC-14.** Pest зелёный (всё новое + не сломано существующее). Vitest зелёный. Histoire 5 новых story'ев билдятся.
- **AC-15.** Pa11y zero violations на /projects (с уже существующими story.vue baseline).
- **AC-16.** lychee 0 broken links в новых .md (plan + spec).

## 11. Открытые вопросы

- **OPEN-Plan5-01.** `assignment_strategy` (round-robin / by-region / по приоритету tenant'а) — в Plan 5 фиксируется как `round-robin` по schema-дефолту. Расширение — Plan 6.
- **OPEN-Plan5-02.** `ttfr_target_minutes` — фиксируется по schema-дефолту. Редактирование через admin SaaS.
- **OPEN-Plan5-03.** Restore из архива — endpoint не делаем в Plan 5. Если нужно — Plan 6 или admin SaaS интерфейс.
- **OPEN-Plan5-04.** Что показывать в `EditProjectDialog` если `last_synced_at` старше N дней — оставляем как есть, без warning. Если потребуется — Plan 6.

## 12. Зависимости

- **Plans 1-4** — все merged на origin/main `4bc488e`. Schema v8.19, supplier sync backend, billing работают.
- **Plan 3 supplier backend** — `SupplierPortalClient` уже умеет всё что нужно для SyncSupplierProjectJob. Plan 5 интегрируется как consumer.
- **Plans 1-4 frontend stack** — Vue 3.5 + Vuetify 3.12 + Pinia 3.0 + axios 1.16 + Histoire 1.0 — без изменений.

## 13. Оценка объёма

Сопоставимо с Plan 4 (12 vertical-slice tasks):

| Категория | Tasks (ориентир) |
|---|---|
| Schema delta + миграция | 1 |
| Backend controllers + requests + service + job | 4-5 |
| Frontend ProjectsView + ProjectCard | 2 |
| NewProjectDialog (3 таба + валидация) | 2 |
| BulkActionsBar + polling store | 1-2 |
| Pest + Vitest + Histoire | покрыты внутри каждой task'и (TDD) |
| Wire-up: router + nav-tree + smoke | 1 |
| **Итого:** | **~11-13 tasks** |

Точная декомпозиция — отдельный план через `superpowers:writing-plans` после approval этого spec'а.
