docs(specs): Plan 5 (Frontend Projects UI + Backend CRUD) implementation design

Spec для full-stack плана: backend CRUD на projects (POST/PATCH/DELETE/sync/bulk),
frontend ProjectsView с карточками+прогресс-баром, NewProjectDialog с 3 табами
(Site/Call/SMS), polling sync-статуса через setTimeout-recursion + backoff,
schema delta v8.19→v8.20 (projects.archived_at).

Через superpowers:brainstorming skill. 11-13 task'ов по vertical-slice TDD
(паттерн Plan 4). Self-review прошёл — 4 inline-фиксы внесены.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-11 17:04:07 +03:00
parent 4bc488e940
commit 1ca4378d14
@@ -0,0 +1,612 @@
# 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: `all` / `whitelist` / `blacklist`. В Plan 5 используем только `all` (пустой `region_mask=0`) и `whitelist` (`region_mask` с битами выбранных регионов). `blacklist` остаётся в schema на будущее (Plan 6+), сейчас в UI недоступен. |
| `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": "all",
"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": "all",
"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": "all",
"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='all')
- Выбраны — 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(['all', 'whitelist', 'blacklist'])],
'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'а.