Files
portal/docs/superpowers/specs/2026-05-11-plan5-frontend-projects-ui-design.md
T
Дмитрий 51019c5aee docs(plan5): rectify region_mode values 'all'/'whitelist'/'blacklist' → 'include'/'exclude'
Schema CHECK constraint on projects.region_mode accepts только 'include'/'exclude'.
Spec/plan изначально использовали 'all'/'whitelist'/'blacklist' (semantic naming),
что не соответствует БД-схеме. При имплементации Task 3 implementer выбрал
'include'/'exclude' (match schema = source of truth). Propagate-fix:

- plan (2 PHP Rule::in + ~10 payload mentions + 4 TS form defaults)
- spec (§4.2 описание, 3 JSON API examples, §6.4 текст, §7.1 StoreProjectRequest)

Чтобы Task 5+ (UpdateProjectRequest, frontend tasks 7-11) не повторили
плановую ошибку.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:40:41 +03:00

31 KiB
Raw Blame History

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) Источники:

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

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:

{
  "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): если хоть один failedfailed; иначе если хоть один pendingpending; иначе ok.

5.2. GET /api/projects/{id}

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

{
  "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):

{
  "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):

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

Ответ 201:

{
  "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. Возвращает:

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

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

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

{ "is_active": false }

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

5.8. POST /api/projects/bulk

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

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

{ "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-логика

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

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

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 backendSupplierPortalClient уже умеет всё что нужно для 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'а.