From 80aa729404e6662f3fddbcbe1d3b960c891f44bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 15 Jun 2026 09:29:27 +0300 Subject: [PATCH] chore: prune Liderra frontend-product specs-plans from claude-brain --- ...6-05-11-plan5-frontend-projects-ui-plan.md | 3121 ----------------- .../2026-05-12-dev-element-indices-plan.md | 1999 ----------- .../plans/2026-05-12-portal-full-audit.md | 611 ---- ...05-12-portal-redesign-quiet-luxury-plan.md | 2607 -------------- .../plans/2026-05-12-projects-bulk-actions.md | 1907 ---------- ...26-05-12-quiet-luxury-elements-1440-896.md | 720 ---- ...26-05-12-remove-r15-motion-restrictions.md | 902 ----- ...2026-05-13-cto-19-lucide-icon-migration.md | 763 ---- .../plans/2026-05-13-pest-p1-quirks-fix.md | 313 -- .../plans/2026-05-13-portal-full-audit-2.md | 1496 -------- ...13-q-defer-003-security-router-coverage.md | 685 ---- .../2026-05-13-q-defer-004-a11y-residual.md | 793 ----- ...pest-parallel-unique-key-collision-plan.md | 279 -- .../2026-05-14-a11y-rescan-live-portal.md | 552 --- .../plans/2026-05-14-pdd-regions-field.md | 282 -- ...2026-05-14-plan-6-regions-subject-level.md | 1334 ------- .../plans/2026-05-14-portal-full-audit-3.md | 998 ------ .../2026-05-14-project-details-drawer.md | 1134 ------ ...ct-migration-redesign-plan-1-foundation.md | 801 ----- ...-migration-redesign-plan-2-distribution.md | 641 ---- ...roject-migration-redesign-plan-3-export.md | 548 --- .../2026-05-21-project-delete-dedup-errors.md | 746 ---- .../2026-05-22-замечания-проекты-чеклист.md | 214 -- ...2026-05-10-roadmap-to-production-design.md | 320 -- ...05-11-plan5-frontend-projects-ui-design.md | 612 ---- ...5-11-roadmap-post-plan5-birdseye-design.md | 139 - .../2026-05-12-dev-element-indices-design.md | 251 -- ...-12-portal-redesign-quiet-luxury-design.md | 361 -- ...2026-05-12-projects-bulk-actions-design.md | 293 -- ...2-quiet-luxury-elements-1440-896-design.md | 271 -- ...2-remove-r15-motion-restrictions-design.md | 161 - ...-13-cto-19-lucide-icon-migration-design.md | 241 -- .../2026-05-13-portal-full-audit-2-design.md | 322 -- .../2026-05-14-pdd-regions-field-design.md | 231 -- ...-14-plan-6-regions-subject-level-design.md | 430 --- ...026-05-14-project-details-drawer-design.md | 324 -- .../specs/2026-05-15-portal-audit-design.md | 322 -- ...05-20-project-migration-redesign-design.md | 149 - ...5-21-project-delete-dedup-errors-design.md | 149 - 39 files changed, 28022 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-11-plan5-frontend-projects-ui-plan.md delete mode 100644 docs/superpowers/plans/2026-05-12-dev-element-indices-plan.md delete mode 100644 docs/superpowers/plans/2026-05-12-portal-full-audit.md delete mode 100644 docs/superpowers/plans/2026-05-12-portal-redesign-quiet-luxury-plan.md delete mode 100644 docs/superpowers/plans/2026-05-12-projects-bulk-actions.md delete mode 100644 docs/superpowers/plans/2026-05-12-quiet-luxury-elements-1440-896.md delete mode 100644 docs/superpowers/plans/2026-05-12-remove-r15-motion-restrictions.md delete mode 100644 docs/superpowers/plans/2026-05-13-cto-19-lucide-icon-migration.md delete mode 100644 docs/superpowers/plans/2026-05-13-pest-p1-quirks-fix.md delete mode 100644 docs/superpowers/plans/2026-05-13-portal-full-audit-2.md delete mode 100644 docs/superpowers/plans/2026-05-13-q-defer-003-security-router-coverage.md delete mode 100644 docs/superpowers/plans/2026-05-13-q-defer-004-a11y-residual.md delete mode 100644 docs/superpowers/plans/2026-05-13-quirk-77-pest-parallel-unique-key-collision-plan.md delete mode 100644 docs/superpowers/plans/2026-05-14-a11y-rescan-live-portal.md delete mode 100644 docs/superpowers/plans/2026-05-14-pdd-regions-field.md delete mode 100644 docs/superpowers/plans/2026-05-14-plan-6-regions-subject-level.md delete mode 100644 docs/superpowers/plans/2026-05-14-portal-full-audit-3.md delete mode 100644 docs/superpowers/plans/2026-05-14-project-details-drawer.md delete mode 100644 docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-1-foundation.md delete mode 100644 docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-2-distribution.md delete mode 100644 docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md delete mode 100644 docs/superpowers/plans/2026-05-21-project-delete-dedup-errors.md delete mode 100644 docs/superpowers/plans/2026-05-22-замечания-проекты-чеклист.md delete mode 100644 docs/superpowers/specs/2026-05-10-roadmap-to-production-design.md delete mode 100644 docs/superpowers/specs/2026-05-11-plan5-frontend-projects-ui-design.md delete mode 100644 docs/superpowers/specs/2026-05-11-roadmap-post-plan5-birdseye-design.md delete mode 100644 docs/superpowers/specs/2026-05-12-dev-element-indices-design.md delete mode 100644 docs/superpowers/specs/2026-05-12-portal-redesign-quiet-luxury-design.md delete mode 100644 docs/superpowers/specs/2026-05-12-projects-bulk-actions-design.md delete mode 100644 docs/superpowers/specs/2026-05-12-quiet-luxury-elements-1440-896-design.md delete mode 100644 docs/superpowers/specs/2026-05-12-remove-r15-motion-restrictions-design.md delete mode 100644 docs/superpowers/specs/2026-05-13-cto-19-lucide-icon-migration-design.md delete mode 100644 docs/superpowers/specs/2026-05-13-portal-full-audit-2-design.md delete mode 100644 docs/superpowers/specs/2026-05-14-pdd-regions-field-design.md delete mode 100644 docs/superpowers/specs/2026-05-14-plan-6-regions-subject-level-design.md delete mode 100644 docs/superpowers/specs/2026-05-14-project-details-drawer-design.md delete mode 100644 docs/superpowers/specs/2026-05-15-portal-audit-design.md delete mode 100644 docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md delete mode 100644 docs/superpowers/specs/2026-05-21-project-delete-dedup-errors-design.md diff --git a/docs/superpowers/plans/2026-05-11-plan5-frontend-projects-ui-plan.md b/docs/superpowers/plans/2026-05-11-plan5-frontend-projects-ui-plan.md deleted file mode 100644 index ece1827..0000000 --- a/docs/superpowers/plans/2026-05-11-plan5-frontend-projects-ui-plan.md +++ /dev/null @@ -1,3121 +0,0 @@ -# Plan 5 (Frontend Projects UI + Backend CRUD) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Tenant Лидерры получает full-stack управление проектами через `/projects`: создание (3 signal_type), редактирование, pause/resume, archive, manual sync, bulk-операции. Backend расширяет `ProjectController` от GET-list-stub до полного REST + dispatch SyncSupplierProjectJob. - -**Architecture:** 12 Tasks в 2 фазах. Фаза I — Backend (Tasks 1–6): schema delta v8.19→v8.20, расширение ProjectController, новый ProjectService + 3 FormRequest, SyncSupplierProjectJob с retry/backoff. Фаза II — Frontend (Tasks 7–11): router/nav, ProjectCard, ProjectsView+store, NewProjectDialog с 3 табами, EditDialog wrapper + BulkActionsBar, polling integration. Task 12 — final smoke + closure. - -**Tech Stack:** PHP 8.3, Laravel 13.7, Pest 4, PostgreSQL 16, Vue 3.5 + Vuetify 3.12, Pinia 3.0, axios 1.16, Vitest 4, Histoire 1.0-beta. Inherits backend supplier-sync infra: SupplierPortalClient (Plan 3 merged). - -**Parent spec:** [2026-05-11-plan5-frontend-projects-ui-design.md](../specs/2026-05-11-plan5-frontend-projects-ui-design.md) (commit `1ca4378`). -**Inherits from:** Plans 1–4 merged на origin/main `4bc488e`. - ---- - -## Карта файлов - -| Файл | Действие | Назначение | Task | -|---|---|---|---| -| [db/schema.sql](../../db/schema.sql) | Modify | +1 колонка `projects.archived_at`, bump v8.19→v8.20 | 1 | -| [db/CHANGELOG_schema.md](../../db/CHANGELOG_schema.md) | Modify | Запись v8.20 | 1 | -| `app/database/migrations/2026_05_11_140000_add_archived_at_to_projects.php` | Create | Migration | 1 | -| `app/app/Models/Project.php` | Modify | +`archived_at` в fillable + datetime cast + scope `active()` | 1 | -| `app/tests/Feature/Plan5/Schema/ArchivedAtTest.php` | Create | Schema delta test | 1 | -| `app/app/Http/Controllers/Api/ProjectController.php` | Modify | Расширение index (filters/search/pagination/ids) + show | 2 | -| `app/app/Http/Resources/ProjectResource.php` | Create | API resource (унифицирует payload) | 2 | -| `app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php` | Create | 7 тестов index+show + RLS | 2 | -| `app/app/Http/Requests/StoreProjectRequest.php` | Create | Conditional rules по signal_type | 3 | -| `app/app/Services/Project/ProjectService.php` | Create | create/update/archive/triggerSync/bulkAction | 3 | -| `app/app/Http/Controllers/Api/ProjectController.php` | Modify | +`store` method | 3 | -| [app/routes/web.php](../../app/routes/web.php) | Modify | +POST `/api/projects` | 3 | -| `app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php` | Create | 8 тестов store + лимит + валидация | 3 | -| `app/app/Jobs/SyncSupplierProjectJob.php` | Create | resolve платформ + SupplierPortalClient calls + retry | 4 | -| `app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php` | Create | 6 тестов (happy + 4 failure + retry) | 4 | -| `app/app/Http/Requests/UpdateProjectRequest.php` | Create | rules без immutable полей | 5 | -| `app/app/Http/Controllers/Api/ProjectController.php` | Modify | +`update` + delivered_today guard | 5 | -| [app/routes/web.php](../../app/routes/web.php) | Modify | +PATCH `/api/projects/{id}` | 5 | -| `app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php` | Create | 6 тестов update + resync на изменении signal-полей | 5 | -| `app/app/Http/Controllers/Api/ProjectController.php` | Modify | +`destroy` + `sync` + `toggle` + `bulk` | 6 | -| `app/app/Http/Requests/BulkProjectActionRequest.php` | Create | action+ids валидация | 6 | -| [app/routes/web.php](../../app/routes/web.php) | Modify | +4 маршрута | 6 | -| `app/tests/Feature/Plan5/Projects/ProjectsActionsTest.php` | Create | 9 тестов destroy/sync/toggle/bulk | 6 | -| `app/resources/js/router/index.ts` | Modify | +route `/projects` | 7 | -| `app/resources/js/components/layout/AppLayout.vue` | Modify | +пункт «Проекты» в nav-tree | 7 | -| `app/resources/js/constants/regions.ts` | Create | 89 регионов РФ (code+name) | 7 | -| `app/resources/js/components/projects/ProjectCard.vue` | Create | Карточка с прогресс-баром + action menu | 7 | -| `app/resources/js/components/projects/ProjectCard.story.vue` | Create | 4 variants (ok/pending/failed/paused) | 7 | -| `app/tests/Frontend/ProjectCard.spec.ts` | Create | 4 Vitest теста | 7 | -| `app/resources/js/stores/projectsStore.ts` | Create | Pinia store (без polling) — list/filters/CRUD actions | 8 | -| `app/resources/js/views/ProjectsView.vue` | Create | Главная страница | 8 | -| `app/resources/js/views/ProjectsView.story.vue` | Create | 3 variants (empty/5/50 projects) | 8 | -| `app/tests/Frontend/ProjectsView.spec.ts` | Create | 4 Vitest теста | 8 | -| `app/tests/Frontend/projectsStore.spec.ts` | Create | 5 Vitest тестов | 8 | -| `app/resources/js/views/projects/NewProjectDialog.vue` | Create | 3 таба (Сайт/Звонок/СМС) + общие поля | 9 | -| `app/resources/js/views/projects/NewProjectDialog.story.vue` | Create | 3 variants (по табу) | 9 | -| `app/tests/Frontend/NewProjectDialog.spec.ts` | Create | 5 Vitest тестов | 9 | -| `app/resources/js/views/projects/EditProjectDialog.vue` | Create | Wrapper над NewProjectDialog | 10 | -| `app/resources/js/components/projects/BulkActionsBar.vue` | Create | Toolbar pause/resume/archive + confirm | 10 | -| `app/resources/js/components/projects/BulkActionsBar.story.vue` | Create | 2 variants (1/many selected) | 10 | -| `app/tests/Frontend/EditProjectDialog.spec.ts` | Create | 3 Vitest теста | 10 | -| `app/tests/Frontend/BulkActionsBar.spec.ts` | Create | 4 Vitest теста | 10 | -| `app/resources/js/stores/projectsStore.ts` | Modify | +polling (setTimeout-recursion + backoff) | 11 | -| `app/tests/Frontend/projectsStore.spec.ts` | Modify | +3 polling тестов (fake timers) | 11 | -| All test runs / Pa11y / lychee | Verify | Финальная проверка | 12 | -| [CLAUDE.md](../../CLAUDE.md) | Modify | §0 schema → v8.20; §6 фаза → Plan 5 closure (через claude-md-management) | 12 | -| [docs/Открытые_вопросы_v8_3.md](../../Открытые_вопросы_v8_3.md) | Modify | +4 OPEN-Plan5-XX в реестр | 12 | - ---- - -## Task 1: Schema delta v8.19 → v8.20 + Project model - -**Files:** - -- Modify: [db/schema.sql](../../db/schema.sql) (`projects.archived_at` колонка + version bump) -- Modify: [db/CHANGELOG_schema.md](../../db/CHANGELOG_schema.md) -- Create: `app/database/migrations/2026_05_11_140000_add_archived_at_to_projects.php` -- Modify: `app/app/Models/Project.php` -- Create: `app/tests/Feature/Plan5/Schema/ArchivedAtTest.php` - -- [ ] **Step 1: Написать failing schema-delta test** - -```php -toBeTrue(); - $type = Schema::getColumnType('projects', 'archived_at'); - expect($type)->toBe('timestamp'); -}); - -it('Project model has archived_at in fillable and casts it to datetime', function () { - $project = new \App\Models\Project(); - expect(in_array('archived_at', $project->getFillable(), true))->toBeTrue(); - expect($project->getCasts()['archived_at'] ?? null)->toBe('datetime'); -}); -``` - -- [ ] **Step 2: Run test — expect FAIL** - -```bash -cd app && composer test -- --filter=ArchivedAtTest -``` - -Expected: 2 failures (column не существует, fillable не содержит). - -- [ ] **Step 3: Создать миграцию** - -```php -timestampTz('archived_at')->nullable(); - }); - } - - public function down(): void - { - Schema::table('projects', function (Blueprint $table) { - $table->dropColumn('archived_at'); - }); - } -}; -``` - -- [ ] **Step 4: Запустить миграцию** - -```bash -cd app && php artisan migrate -``` - -Expected: `INFO Running migrations.` + `2026_05_11_140000_add_archived_at_to_projects ... DONE`. - -- [ ] **Step 5: Обновить schema.sql (источник истины)** - -В [db/schema.sql](../../db/schema.sql): - -1. Найти секцию `CREATE TABLE projects (` и добавить колонку: - -```sql -archived_at TIMESTAMPTZ NULL, -- v8.20 (Plan 5) -``` - -(Положение: после `updated_at` или в логичном месте секции). - -1. Обновить шапку файла: - -```sql --- Версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at) -``` - -1. Обновить метрики (62→62 базовые таблицы, индексов столько же; одна колонка не добавляет ничего). - -- [ ] **Step 6: Запись в CHANGELOG_schema.md** - -В [db/CHANGELOG_schema.md](../../db/CHANGELOG_schema.md) добавить раздел v8.20 в начало: - -```markdown -## v8.20 (11.05.2026 — Plan 5) - -**Added:** -- `projects.archived_at TIMESTAMPTZ NULL` — для soft archive flow (отличие от `is_active=false` который = pause). - -**Migration:** `app/database/migrations/2026_05_11_140000_add_archived_at_to_projects.php` -``` - -- [ ] **Step 7: Обновить Project model** - -В `app/app/Models/Project.php`: - -1. Добавить `'archived_at'` в массив `$fillable` (после `'is_active'`). -2. В `casts()` добавить `'archived_at' => 'datetime'`. -3. Добавить scope: - -```php -public function scopeActive($query) -{ - return $query->whereNull('archived_at'); -} - -public function scopeArchived($query) -{ - return $query->whereNotNull('archived_at'); -} -``` - -- [ ] **Step 8: Run test — expect PASS** - -```bash -cd app && composer test -- --filter=ArchivedAtTest -``` - -Expected: 2 passed. - -- [ ] **Step 9: Verify schema.sql idempotent fresh applies** - -Test through `php artisan migrate:fresh` (на dev-БД) — что schema.sql + миграция дают идентичный результат: - -```bash -cd app && php artisan migrate:fresh && php artisan migrate -``` - -Expected: fresh применяет load_initial_schema, потом migrate применяет archived_at; вторая migrate — Nothing to migrate. - -- [ ] **Step 10: Commit** - -```bash -git add db/schema.sql db/CHANGELOG_schema.md \ - app/database/migrations/2026_05_11_140000_add_archived_at_to_projects.php \ - app/app/Models/Project.php \ - app/tests/Feature/Plan5/Schema/ArchivedAtTest.php -git commit -m "feat(db): Plan 5 Task 1 — schema delta v8.19 → v8.20 + Project.archived_at" -``` - ---- - -## Task 2: Backend index/show расширенный - -**Files:** - -- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (расширить `index()` + добавить `show()`) -- Create: `app/app/Http/Resources/ProjectResource.php` -- Create: `app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php` - -- [ ] **Step 1: Написать failing test для index** - -```php -create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - Project::factory()->count(3)->create(['tenant_id' => $tenant->id, 'signal_type' => 'site']); - - $response = $this->actingAs($user)->getJson('/api/projects'); - - $response->assertOk(); - $response->assertJsonStructure([ - 'data' => [['id', 'name', 'signal_type', 'signal_identifier', 'daily_limit_target', - 'delivered_today', 'is_active', 'archived_at', 'sync_status']], - 'meta' => ['current_page', 'per_page', 'total'], - ]); - expect($response->json('meta.total'))->toBe(3); -}); - -it('filters list by signal_type', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - Project::factory()->create(['tenant_id' => $tenant->id, 'signal_type' => 'site']); - Project::factory()->create(['tenant_id' => $tenant->id, 'signal_type' => 'call']); - - $response = $this->actingAs($user)->getJson('/api/projects?signal_type=site'); - - expect($response->json('meta.total'))->toBe(1); -}); - -it('isolates projects per tenant (RLS)', function () { - $tenantA = Tenant::factory()->create(); - $tenantB = Tenant::factory()->create(); - $userA = User::factory()->create(['tenant_id' => $tenantA->id]); - Project::factory()->count(2)->create(['tenant_id' => $tenantA->id]); - Project::factory()->count(5)->create(['tenant_id' => $tenantB->id]); - - $response = $this->actingAs($userA)->getJson('/api/projects'); - - expect($response->json('meta.total'))->toBe(2); -}); - -it('excludes archived projects by default', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - Project::factory()->create(['tenant_id' => $tenant->id]); - Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]); - - $response = $this->actingAs($user)->getJson('/api/projects'); - - expect($response->json('meta.total'))->toBe(1); -}); - -it('returns archived when status=archived requested', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]); - - $response = $this->actingAs($user)->getJson('/api/projects?status=archived'); - - expect($response->json('meta.total'))->toBe(1); -}); - -it('returns batch fetch by ids without pagination', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $projects = Project::factory()->count(3)->create(['tenant_id' => $tenant->id]); - $ids = $projects->pluck('id')->take(2)->implode(','); - - $response = $this->actingAs($user)->getJson("/api/projects?ids={$ids}"); - - expect(count($response->json('data')))->toBe(2); -}); - -it('show returns project with supplier_links array', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $project = Project::factory()->create(['tenant_id' => $tenant->id]); - - $response = $this->actingAs($user)->getJson("/api/projects/{$project->id}"); - - $response->assertOk(); - $response->assertJsonStructure(['data' => ['id', 'name', 'supplier_links']]); -}); -``` - -- [ ] **Step 2: Run — expect FAIL** (controller возвращает старый формат) - -```bash -cd app && composer test -- --filter=ProjectsListShowTest -``` - -- [ ] **Step 3: Создать ProjectResource** - -```php - $this->id, - 'name' => $this->name, - 'signal_type' => $this->signal_type, - 'signal_identifier' => $this->signal_identifier, - 'sms_senders' => $this->sms_senders, - 'sms_keyword' => $this->sms_keyword, - 'daily_limit_target' => $this->daily_limit_target, - 'effective_daily_limit_today' => $this->effective_daily_limit_today, - 'delivered_today' => $this->delivered_today, - 'delivered_in_month' => $this->delivered_in_month, - 'is_active' => $this->is_active, - 'archived_at' => $this->archived_at?->toIso8601String(), - 'region_mask' => $this->region_mask, - 'region_mode' => $this->region_mode, - 'delivery_days_mask' => $this->delivery_days_mask, - 'sync_status' => $this->aggregateSyncStatus(), - 'last_synced_at' => $this->aggregateLastSyncedAt(), - 'supplier_links' => $this->when( - $request->routeIs('projects.show'), - fn () => $this->getSupplierLinks(), - ), - ]; - } -} -``` - -В `app/app/Models/Project.php` добавить методы (если нет существующих): - -```php -public function aggregateSyncStatus(): string -{ - $statuses = collect(['b1' => 'supplier_b1_project_id', 'b2' => 'supplier_b2_project_id', 'b3' => 'supplier_b3_project_id']) - ->map(fn ($col) => $this->{$col} ? SupplierProject::find($this->{$col})?->sync_status : null) - ->filter(); - if ($statuses->isEmpty()) { - return 'pending'; - } - if ($statuses->contains('failed')) { - return 'failed'; - } - if ($statuses->contains('pending')) { - return 'pending'; - } - return 'ok'; -} - -public function aggregateLastSyncedAt(): ?string -{ - $ts = collect(['supplier_b1_project_id', 'supplier_b2_project_id', 'supplier_b3_project_id']) - ->map(fn ($col) => $this->{$col} ? SupplierProject::find($this->{$col})?->last_synced_at : null) - ->filter() - ->min(); - return $ts?->toIso8601String(); -} - -public function getSupplierLinks(): array -{ - return collect(['b1', 'b2', 'b3']) - ->map(fn ($p) => $this->{"supplier_{$p}_project_id"} - ? ['platform' => $p, 'supplier_project_id' => $this->{"supplier_{$p}_project_id"}, - 'sync_status' => SupplierProject::find($this->{"supplier_{$p}_project_id"})?->sync_status, - 'last_synced_at' => SupplierProject::find($this->{"supplier_{$p}_project_id"})?->last_synced_at?->toIso8601String()] - : null) - ->filter() - ->values() - ->all(); -} -``` - -Импорт: `use App\Models\SupplierProject;` - -- [ ] **Step 4: Заменить ProjectController на расширенный** - -```php -where('tenant_id', $request->user()->tenant_id); - - if ($ids = $request->query('ids')) { - $idArray = array_filter(array_map('intval', explode(',', (string) $ids))); - $items = $query->whereIn('id', $idArray)->get(); - return response()->json(['data' => ProjectResource::collection($items)]); - } - - if ($type = $request->query('signal_type')) { - $query->where('signal_type', $type); - } - - $status = $request->query('status'); - if ($status === 'archived') { - $query->archived(); - } elseif ($status === 'active') { - $query->active()->where('is_active', true); - } elseif ($status === 'paused') { - $query->active()->where('is_active', false); - } else { - $query->active(); // default — active + paused, без archived - } - - if ($search = $request->query('search')) { - $query->where(function ($q) use ($search) { - $q->where('name', 'ilike', "%{$search}%") - ->orWhere('signal_identifier', 'ilike', "%{$search}%"); - }); - } - - $perPage = min((int) $request->query('per_page', '20'), 100); - $projects = $query->orderBy('created_at', 'desc')->paginate($perPage); - - return response()->json([ - 'data' => ProjectResource::collection($projects->items()), - 'meta' => [ - 'current_page' => $projects->currentPage(), - 'per_page' => $projects->perPage(), - 'total' => $projects->total(), - ], - ]); - } - - public function show(Request $request, int $id): JsonResponse - { - $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); - return response()->json(['data' => new ProjectResource($project)]); - } -} -``` - -- [ ] **Step 5: Обновить routes/web.php** - -Заменить старый `Route::get('/api/projects', ...)` на: - -```php -Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/projects')->group(function () { - Route::get('/', 'App\Http\Controllers\Api\ProjectController@index')->name('projects.index'); - Route::get('/{id}', 'App\Http\Controllers\Api\ProjectController@show')->name('projects.show')->where('id', '[0-9]+'); -}); -``` - -⚠️ **Внимание:** существующий dropdown `NewDealDialog` использует старый `GET /api/projects?tenant_id={id}`. После изменения он получает 401 (нет auth). Если `NewDealDialog` ещё активен в frontend — добавить временный backward-compat fallback или обновить NewDealDialog в Task 7 при добавлении nav-tree. **Решение:** обновить в Task 7 (frontend phase). - -- [ ] **Step 6: Run test — expect PASS** - -```bash -cd app && composer test -- --filter=ProjectsListShowTest -``` - -Expected: 7 passed. - -- [ ] **Step 7: Commit** - -```bash -git add app/app/Http/Controllers/Api/ProjectController.php \ - app/app/Http/Resources/ProjectResource.php \ - app/app/Models/Project.php \ - app/routes/web.php \ - app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php -git commit -m "feat(projects): Plan 5 Task 2 — index expanded (filters/search/pagination/ids) + show" -``` - ---- - -## Task 3: Backend store + StoreProjectRequest + ProjectService - -**Files:** - -- Create: `app/app/Http/Requests/StoreProjectRequest.php` -- Create: `app/app/Services/Project/ProjectService.php` -- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (+store) -- Modify: `app/routes/web.php` (+POST) -- Create: `app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php` - -- [ ] **Step 1: Написать failing test для store** - -```php - Queue::fake()); - -it('creates a site project with valid payload', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - - $response = $this->actingAs($user)->postJson('/api/projects', [ - 'name' => 'Окна СПб', - 'signal_type' => 'site', - 'signal_identifier' => 'okna-spb.ru', - 'daily_limit_target' => 50, - 'region_mask' => 0, - 'region_mode' => 'include', - 'delivery_days_mask' => 127, - ]); - - $response->assertCreated(); - expect(Project::where('signal_identifier', 'okna-spb.ru')->exists())->toBeTrue(); - Queue::assertPushed(SyncSupplierProjectJob::class); -}); - -it('rejects invalid site domain', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - - $response = $this->actingAs($user)->postJson('/api/projects', [ - 'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'not a domain', - 'daily_limit_target' => 50, 'region_mask' => 0, 'region_mode' => 'include', - 'delivery_days_mask' => 127, - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors(['signal_identifier']); -}); - -it('creates a call project with valid 11-digit phone', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - - $response = $this->actingAs($user)->postJson('/api/projects', [ - 'name' => 'Натяжные', 'signal_type' => 'call', 'signal_identifier' => '79161234567', - 'daily_limit_target' => 30, 'region_mask' => 0, 'region_mode' => 'include', - 'delivery_days_mask' => 127, - ]); - - $response->assertCreated(); -}); - -it('rejects call signal_identifier not starting with 7', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - - $response = $this->actingAs($user)->postJson('/api/projects', [ - 'name' => 'X', 'signal_type' => 'call', 'signal_identifier' => '89991234567', - 'daily_limit_target' => 30, 'region_mask' => 0, 'region_mode' => 'include', - 'delivery_days_mask' => 127, - ]); - - $response->assertStatus(422); -}); - -it('creates sms project with senders + keyword', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - - $response = $this->actingAs($user)->postJson('/api/projects', [ - 'name' => 'Ипотека', 'signal_type' => 'sms', - 'sms_senders' => ['TINKOFF'], 'sms_keyword' => 'ипотека', - 'daily_limit_target' => 100, 'region_mask' => 0, 'region_mode' => 'include', - 'delivery_days_mask' => 127, - ]); - - $response->assertCreated(); - $project = Project::where('name', 'Ипотека')->first(); - expect($project->sms_senders)->toBe(['TINKOFF']); - expect($project->sms_keyword)->toBe('ипотека'); -}); - -it('rejects sms project without sms_senders', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - - $response = $this->actingAs($user)->postJson('/api/projects', [ - 'name' => 'X', 'signal_type' => 'sms', - 'daily_limit_target' => 100, 'region_mask' => 0, 'region_mode' => 'include', - 'delivery_days_mask' => 127, - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors(['sms_senders']); -}); - -it('rejects when tenant exceeds max_projects limit', function () { - $tenant = Tenant::factory()->create(['limits' => ['max_projects' => 1]]); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - Project::factory()->create(['tenant_id' => $tenant->id]); - - $response = $this->actingAs($user)->postJson('/api/projects', [ - 'name' => 'second', 'signal_type' => 'site', 'signal_identifier' => 'second.ru', - 'daily_limit_target' => 10, 'region_mask' => 0, 'region_mode' => 'include', - 'delivery_days_mask' => 127, - ]); - - $response->assertStatus(403); -}); - -it('forces tenant_id from auth user (not from payload)', function () { - $tenantA = Tenant::factory()->create(); - $tenantB = Tenant::factory()->create(); - $userA = User::factory()->create(['tenant_id' => $tenantA->id]); - - $this->actingAs($userA)->postJson('/api/projects', [ - 'tenant_id' => $tenantB->id, // попытка инъекции - 'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'x.ru', - 'daily_limit_target' => 10, 'region_mask' => 0, 'region_mode' => 'include', - 'delivery_days_mask' => 127, - ]); - - $project = Project::first(); - expect($project->tenant_id)->toBe($tenantA->id); -}); -``` - -- [ ] **Step 2: Run — expect FAIL** (нет POST route, нет StoreProjectRequest) - -```bash -cd app && composer test -- --filter=ProjectsStoreTest -``` - -- [ ] **Step 3: Создать StoreProjectRequest** - -```php -user() !== null; - } - - public function rules(): array - { - $signalType = $this->input('signal_type'); - - $base = [ - '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') { - $base['signal_identifier'] = ['required', 'string', 'regex:/^[a-z0-9.\-]+\.[a-z]{2,}$/i']; - } elseif ($signalType === 'call') { - $base['signal_identifier'] = ['required', 'string', 'regex:/^7\d{10}$/']; - } elseif ($signalType === 'sms') { - $base['sms_senders'] = ['required', 'array', 'min:1']; - $base['sms_senders.*'] = ['string', 'max:11']; - $base['sms_keyword'] = ['nullable', 'string', 'max:50']; - } - - return $base; - } -} -``` - -- [ ] **Step 4: Создать ProjectService** - -```php -limits['max_projects'] ?? 10); - $current = Project::where('tenant_id', $tenant->id)->active()->count(); - if ($current >= $limit) { - throw new HttpResponseException(response()->json([ - 'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.", - ], 403)); - } - - $data['tenant_id'] = $tenant->id; - $data['is_active'] = true; - $project = Project::create($data); - - SyncSupplierProjectJob::dispatch($project->id); - - return $project->fresh(); - } -} -``` - -- [ ] **Step 5: Расширить ProjectController::store** - -```php -use App\Http\Requests\StoreProjectRequest; -use App\Services\Project\ProjectService; - -public function __construct(private readonly ProjectService $projects) {} - -public function store(StoreProjectRequest $request): JsonResponse -{ - $project = $this->projects->create($request->user()->tenant, $request->validated()); - return response()->json(['data' => new ProjectResource($project)], 201); -} -``` - -- [ ] **Step 6: Создать stub SyncSupplierProjectJob (для прохождения теста)** - -Минимальный stub, чтобы Queue::fake поймал dispatch. Полная реализация — Task 4. - -```php -name('projects.store'); -``` - -- [ ] **Step 8: Run test — expect PASS** - -```bash -cd app && composer test -- --filter=ProjectsStoreTest -``` - -Expected: 8 passed. - -- [ ] **Step 9: Commit** - -```bash -git add app/app/Http/Requests/StoreProjectRequest.php \ - app/app/Services/Project/ProjectService.php \ - app/app/Jobs/SyncSupplierProjectJob.php \ - app/app/Http/Controllers/Api/ProjectController.php \ - app/routes/web.php \ - app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php -git commit -m "feat(projects): Plan 5 Task 3 — store + StoreProjectRequest + ProjectService::create" -``` - ---- - -## Task 4: SyncSupplierProjectJob (полная реализация) - -**Files:** - -- Modify: `app/app/Jobs/SyncSupplierProjectJob.php` (заменить stub) -- Create: `app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php` - -- [ ] **Step 1: Написать failing test** - -```php -create(); - $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'signal_type' => 'site', - 'signal_identifier' => 'okna.ru', - ]); - - $this->mock(SupplierPortalClient::class, function ($mock) { - $mock->shouldReceive('ensureSupplierProject')->times(3) - ->andReturnUsing(fn ($platform, $signalType, $key) => - SupplierProject::factory()->create([ - 'platform' => $platform, - 'signal_type' => $signalType, - 'unique_key' => $key, - 'sync_status' => 'ok', - ])->id - ); - }); - - (new SyncSupplierProjectJob($project->id))->handle(); - - $project->refresh(); - expect($project->supplier_b1_project_id)->not->toBeNull(); - expect($project->supplier_b2_project_id)->not->toBeNull(); - expect($project->supplier_b3_project_id)->not->toBeNull(); -}); - -it('call project: links B1+B2+B3 with phone signal_identifier', function () { - $project = Project::factory()->create([ - 'signal_type' => 'call', - 'signal_identifier' => '79161234567', - ]); - - $this->mock(SupplierPortalClient::class, function ($mock) { - $mock->shouldReceive('ensureSupplierProject')->times(3) - ->andReturn(SupplierProject::factory()->create(['sync_status' => 'ok'])->id); - }); - - (new SyncSupplierProjectJob($project->id))->handle(); - - expect($project->fresh()->supplier_b1_project_id)->not->toBeNull(); -}); - -it('sms project with keyword: links B2+B3 only (no B1)', function () { - $project = Project::factory()->create([ - 'signal_type' => 'sms', - 'sms_senders' => ['TINKOFF'], - 'sms_keyword' => 'ипотека', - ]); - - $this->mock(SupplierPortalClient::class, function ($mock) { - $mock->shouldReceive('ensureSupplierProject')->times(2) - ->andReturn(SupplierProject::factory()->create(['sync_status' => 'ok'])->id); - }); - - (new SyncSupplierProjectJob($project->id))->handle(); - - $project->refresh(); - expect($project->supplier_b1_project_id)->toBeNull(); - expect($project->supplier_b2_project_id)->not->toBeNull(); - expect($project->supplier_b3_project_id)->not->toBeNull(); -}); - -it('sms project without keyword: links B3 only', function () { - $project = Project::factory()->create([ - 'signal_type' => 'sms', - 'sms_senders' => ['TINKOFF'], - 'sms_keyword' => null, - ]); - - $this->mock(SupplierPortalClient::class, function ($mock) { - $mock->shouldReceive('ensureSupplierProject')->once() - ->andReturn(SupplierProject::factory()->create(['sync_status' => 'ok'])->id); - }); - - (new SyncSupplierProjectJob($project->id))->handle(); - - $project->refresh(); - expect($project->supplier_b1_project_id)->toBeNull(); - expect($project->supplier_b2_project_id)->toBeNull(); - expect($project->supplier_b3_project_id)->not->toBeNull(); -}); - -it('portal timeout: throws exception for retry', function () { - $project = Project::factory()->create(['signal_type' => 'site', 'signal_identifier' => 'x.ru']); - - $this->mock(SupplierPortalClient::class, function ($mock) { - $mock->shouldReceive('ensureSupplierProject')->andThrow(new \RuntimeException('timeout')); - }); - - expect(fn () => (new SyncSupplierProjectJob($project->id))->handle()) - ->toThrow(\RuntimeException::class); -}); - -it('partial success on site: B1 ok, B2 failed — sync_status="failed" on B2 row', function () { - $project = Project::factory()->create(['signal_type' => 'site', 'signal_identifier' => 'x.ru']); - - $this->mock(SupplierPortalClient::class, function ($mock) { - $sp1 = SupplierProject::factory()->create(['sync_status' => 'ok'])->id; - $sp2 = SupplierProject::factory()->create(['sync_status' => 'failed', 'last_error' => 'sender_conflict'])->id; - $sp3 = SupplierProject::factory()->create(['sync_status' => 'ok'])->id; - $mock->shouldReceive('ensureSupplierProject')->andReturn($sp1, $sp2, $sp3); - }); - - (new SyncSupplierProjectJob($project->id))->handle(); - - $project->refresh(); - expect($project->supplier_b2_project_id)->not->toBeNull(); - expect(SupplierProject::find($project->supplier_b2_project_id)->sync_status)->toBe('failed'); -}); -``` - -- [ ] **Step 2: Run — expect FAIL** (stub job ничего не делает) - -- [ ] **Step 3: Реализация SyncSupplierProjectJob** - -```php -projectId); - if (!$project) { - Log::warning("SyncSupplierProjectJob: project {$this->projectId} not found"); - return; - } - - $platforms = $this->resolvePlatforms($project); - - foreach ($platforms as $platform) { - $uniqueKey = $this->buildUniqueKey($project, $platform); - $supplierProjectId = $client->ensureSupplierProject($platform, $project->signal_type, $uniqueKey); - $column = "supplier_{$platform}_project_id"; - $project->{$column} = $supplierProjectId; - } - $project->save(); - } - - /** @return array */ - private function resolvePlatforms(Project $p): array - { - if (in_array($p->signal_type, ['site', 'call'], true)) { - return ['b1', 'b2', 'b3']; - } - if ($p->signal_type === 'sms') { - return $p->sms_keyword ? ['b2', 'b3'] : ['b3']; - } - return []; - } - - private function buildUniqueKey(Project $p, string $platform): string - { - if (in_array($p->signal_type, ['site', 'call'], true)) { - return $p->signal_identifier; - } - // sms - $sender = $p->sms_senders[0] ?? ''; - if ($platform === 'b2') { - return $sender . '+' . ($p->sms_keyword ?? ''); - } - return $sender; // b3 - } -} -``` - -⚠️ **Зависимость:** `SupplierPortalClient::ensureSupplierProject(string $platform, string $signalType, string $key): int` — нужно проверить, что этот метод существует. Если нет — добавить его в SupplierPortalClient (Plan 3 уже расширял этот класс; добавьте wrapper если sign не совпадает). - -- [ ] **Step 4: Run test — expect PASS** - -```bash -cd app && composer test -- --filter=SyncSupplierProjectJobTest -``` - -Expected: 6 passed. - -- [ ] **Step 5: Commit** - -```bash -git add app/app/Jobs/SyncSupplierProjectJob.php \ - app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php -git commit -m "feat(jobs): Plan 5 Task 4 — SyncSupplierProjectJob full impl + 6 tests" -``` - ---- - -## Task 5: Backend update + UpdateProjectRequest - -**Files:** - -- Create: `app/app/Http/Requests/UpdateProjectRequest.php` -- Modify: `app/app/Services/Project/ProjectService.php` (+update) -- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (+update) -- Modify: `app/routes/web.php` (+PATCH) -- Create: `app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php` - -- [ ] **Step 1: Написать failing test** - -```php - Queue::fake()); - -it('updates name+daily_limit without resync', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, 'signal_type' => 'site', - 'signal_identifier' => 'a.ru', 'daily_limit_target' => 10, - ]); - - $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ - 'name' => 'New name', 'daily_limit_target' => 50, - ])->assertOk(); - - expect($project->fresh()->name)->toBe('New name'); - expect($project->fresh()->daily_limit_target)->toBe(50); - Queue::assertNotPushed(SyncSupplierProjectJob::class); -}); - -it('changing sms_senders triggers resync', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, 'signal_type' => 'sms', - 'sms_senders' => ['OLD'], 'sms_keyword' => 'x', - ]); - - $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ - 'sms_senders' => ['NEW'], - ])->assertOk(); - - Queue::assertPushed(SyncSupplierProjectJob::class); -}); - -it('rejects daily_limit_target below delivered_today', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, 'daily_limit_target' => 50, 'delivered_today' => 30, - ]); - - $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ - 'daily_limit_target' => 20, - ])->assertStatus(422); -}); - -it('rejects update of signal_type (immutable)', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $project = Project::factory()->create(['tenant_id' => $tenant->id, 'signal_type' => 'site']); - - $response = $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ - 'signal_type' => 'call', - ]); - - // signal_type должен быть проигнорирован (не падает 422, но и не меняется) - expect($project->fresh()->signal_type)->toBe('site'); -}); - -it('cross-tenant update returns 404', function () { - $tenantA = Tenant::factory()->create(); - $tenantB = Tenant::factory()->create(); - $userA = User::factory()->create(['tenant_id' => $tenantA->id]); - $project = Project::factory()->create(['tenant_id' => $tenantB->id]); - - $this->actingAs($userA)->patchJson("/api/projects/{$project->id}", [ - 'name' => 'hack', - ])->assertStatus(404); -}); - -it('updates region_mask and delivery_days_mask', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $project = Project::factory()->create(['tenant_id' => $tenant->id]); - - $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ - 'region_mask' => 78, 'region_mode' => 'exclude', 'delivery_days_mask' => 31, - ])->assertOk(); - - expect($project->fresh()->region_mask)->toBe(78); -}); -``` - -- [ ] **Step 2: Run — expect FAIL** - -- [ ] **Step 3: Создать UpdateProjectRequest** - -```php -user() !== null; - } - - public function rules(): array - { - // signal_type immutable: не валидируется в правилах, controller игнорирует поле - return [ - 'name' => ['sometimes', 'string', 'max:255'], - 'daily_limit_target' => ['sometimes', 'integer', 'min:1', 'max:10000'], - 'region_mask' => ['sometimes', 'integer', 'min:0'], - 'region_mode' => ['sometimes', Rule::in(['include', 'exclude'])], - 'delivery_days_mask' => ['sometimes', 'integer', 'min:1', 'max:127'], - 'sms_senders' => ['sometimes', 'array', 'min:1'], - 'sms_senders.*' => ['string', 'max:11'], - 'sms_keyword' => ['sometimes', 'nullable', 'string', 'max:50'], - ]; - } -} -``` - -- [ ] **Step 4: Расширить ProjectService::update** - -```php -public function update(Project $project, array $data): Project -{ - // Immutable fields drop - unset($data['tenant_id'], $data['signal_type'], $data['signal_identifier'], - $data['delivered_today'], $data['delivered_in_month'], - $data['supplier_b1_project_id'], $data['supplier_b2_project_id'], - $data['supplier_b3_project_id'], $data['archived_at']); - - if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) { - throw new HttpResponseException(response()->json([ - 'errors' => ['daily_limit_target' => [ - "Лимит не может быть меньше уже доставленных лидов сегодня ({$project->delivered_today}).", - ]], - ], 422)); - } - - $syncSensitive = array_intersect_key($data, array_flip(['sms_senders', 'sms_keyword'])); - $needsResync = !empty($syncSensitive); - - $project->update($data); - - if ($needsResync) { - SyncSupplierProjectJob::dispatch($project->id); - } - - return $project->fresh(); -} -``` - -Импорт: `use App\Jobs\SyncSupplierProjectJob;` в ProjectService. - -- [ ] **Step 5: Добавить ProjectController::update** - -```php -public function update(UpdateProjectRequest $request, int $id): JsonResponse -{ - $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); - $updated = $this->projects->update($project, $request->validated()); - return response()->json(['data' => new ProjectResource($updated)]); -} -``` - -Импорт: `use App\Http\Requests\UpdateProjectRequest;` - -- [ ] **Step 6: +PATCH route** - -```php -Route::patch('/{id}', 'App\Http\Controllers\Api\ProjectController@update')->name('projects.update')->where('id', '[0-9]+'); -``` - -- [ ] **Step 7: Run test — expect PASS** (6 tests) - -```bash -cd app && composer test -- --filter=ProjectsUpdateTest -``` - -- [ ] **Step 8: Commit** - -```bash -git add app/app/Http/Requests/UpdateProjectRequest.php \ - app/app/Services/Project/ProjectService.php \ - app/app/Http/Controllers/Api/ProjectController.php \ - app/routes/web.php \ - app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php -git commit -m "feat(projects): Plan 5 Task 5 — update + UpdateProjectRequest + resync trigger" -``` - ---- - -## Task 6: Backend destroy + sync + toggle + bulk - -**Files:** - -- Create: `app/app/Http/Requests/BulkProjectActionRequest.php` -- Modify: `app/app/Services/Project/ProjectService.php` (+archive, triggerSync, bulkAction) -- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (+destroy, sync, toggle, bulk) -- Modify: `app/routes/web.php` (+4 routes) -- Create: `app/tests/Feature/Plan5/Projects/ProjectsActionsTest.php` - -- [ ] **Step 1: Написать failing test** - -```php - Queue::fake()); - -it('destroy archives project (sets archived_at, is_active=false)', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); - - $this->actingAs($user)->deleteJson("/api/projects/{$project->id}")->assertNoContent(); - - $project->refresh(); - expect($project->is_active)->toBeFalse(); - expect($project->archived_at)->not->toBeNull(); -}); - -it('destroy returns 409 if already archived', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $project = Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]); - - $this->actingAs($user)->deleteJson("/api/projects/{$project->id}")->assertStatus(409); -}); - -it('sync re-dispatches SyncSupplierProjectJob', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $project = Project::factory()->create(['tenant_id' => $tenant->id]); - - $this->actingAs($user)->postJson("/api/projects/{$project->id}/sync") - ->assertStatus(202) - ->assertJsonPath('queued', true); - - Queue::assertPushed(SyncSupplierProjectJob::class); -}); - -it('toggle-active flips is_active flag', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); - - $this->actingAs($user)->patchJson("/api/projects/{$project->id}/toggle-active", ['is_active' => false]) - ->assertOk(); - - expect($project->fresh()->is_active)->toBeFalse(); -}); - -it('bulk pause sets is_active=false on multiple', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $p1 = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); - $p2 = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); - - $this->actingAs($user)->postJson('/api/projects/bulk', [ - 'action' => 'pause', 'ids' => [$p1->id, $p2->id], - ])->assertOk()->assertJsonPath('updated', 2); - - expect($p1->fresh()->is_active)->toBeFalse(); - expect($p2->fresh()->is_active)->toBeFalse(); -}); - -it('bulk filters out cross-tenant ids silently', function () { - $tenantA = Tenant::factory()->create(); - $tenantB = Tenant::factory()->create(); - $userA = User::factory()->create(['tenant_id' => $tenantA->id]); - $pA = Project::factory()->create(['tenant_id' => $tenantA->id, 'is_active' => true]); - $pB = Project::factory()->create(['tenant_id' => $tenantB->id, 'is_active' => true]); - - $this->actingAs($userA)->postJson('/api/projects/bulk', [ - 'action' => 'pause', 'ids' => [$pA->id, $pB->id], - ])->assertOk()->assertJsonPath('updated', 1); - - expect($pB->fresh()->is_active)->toBeTrue(); // not touched -}); - -it('bulk archive sets archived_at on multiple', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - $p1 = Project::factory()->create(['tenant_id' => $tenant->id]); - - $this->actingAs($user)->postJson('/api/projects/bulk', [ - 'action' => 'archive', 'ids' => [$p1->id], - ])->assertOk(); - - expect($p1->fresh()->archived_at)->not->toBeNull(); -}); - -it('bulk rejects > 100 ids', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - - $this->actingAs($user)->postJson('/api/projects/bulk', [ - 'action' => 'pause', 'ids' => range(1, 101), - ])->assertStatus(422); -}); - -it('bulk rejects unknown action', function () { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); - - $this->actingAs($user)->postJson('/api/projects/bulk', [ - 'action' => 'destroy_all', 'ids' => [1], - ])->assertStatus(422); -}); -``` - -- [ ] **Step 2: Run — expect FAIL** - -- [ ] **Step 3: Создать BulkProjectActionRequest** - -```php -user() !== null; - } - - public function rules(): array - { - return [ - 'action' => ['required', Rule::in(['pause', 'resume', 'archive'])], - 'ids' => ['required', 'array', 'min:1', 'max:100'], - 'ids.*' => ['integer', 'min:1'], - ]; - } -} -``` - -- [ ] **Step 4: Расширить ProjectService** - -```php -public function archive(Project $project): void -{ - if ($project->archived_at !== null) { - throw new HttpResponseException(response()->json([ - 'message' => 'Project уже архивирован.', - ], 409)); - } - $project->update([ - 'is_active' => false, - 'archived_at' => now(), - ]); -} - -public function triggerSync(Project $project): void -{ - SyncSupplierProjectJob::dispatch($project->id); -} - -public function bulkAction(int $tenantId, string $action, array $ids): int -{ - $query = Project::where('tenant_id', $tenantId)->whereIn('id', $ids); - - $update = match ($action) { - 'pause' => ['is_active' => false], - 'resume' => ['is_active' => true], - 'archive' => ['is_active' => false, 'archived_at' => now()], - }; - - return $query->update($update); -} -``` - -- [ ] **Step 5: Расширить ProjectController** - -```php -use App\Http\Requests\BulkProjectActionRequest; - -public function destroy(Request $request, int $id): JsonResponse -{ - $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); - $this->projects->archive($project); - return response()->json(null, 204); -} - -public function sync(Request $request, int $id): JsonResponse -{ - $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); - $this->projects->triggerSync($project); - return response()->json(['queued' => true, 'sync_status' => 'pending'], 202); -} - -public function toggleActive(Request $request, int $id): JsonResponse -{ - $request->validate(['is_active' => ['required', 'boolean']]); - $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); - $project->update(['is_active' => $request->boolean('is_active')]); - return response()->json(['data' => new ProjectResource($project->fresh())]); -} - -public function bulk(BulkProjectActionRequest $request): JsonResponse -{ - $updated = $this->projects->bulkAction( - $request->user()->tenant_id, - $request->validated('action'), - $request->validated('ids'), - ); - return response()->json(['updated' => $updated]); -} -``` - -- [ ] **Step 6: +4 routes** - -```php -Route::delete('/{id}', 'App\Http\Controllers\Api\ProjectController@destroy')->name('projects.destroy')->where('id', '[0-9]+'); -Route::post('/{id}/sync', 'App\Http\Controllers\Api\ProjectController@sync')->name('projects.sync')->where('id', '[0-9]+'); -Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+'); -Route::post('/bulk', 'App\Http\Controllers\Api\ProjectController@bulk')->name('projects.bulk'); -``` - -- [ ] **Step 7: Run test — expect PASS** (9 tests) - -```bash -cd app && composer test -- --filter=ProjectsActionsTest -``` - -- [ ] **Step 8: Commit** - -```bash -git add app/app/Http/Requests/BulkProjectActionRequest.php \ - app/app/Services/Project/ProjectService.php \ - app/app/Http/Controllers/Api/ProjectController.php \ - app/routes/web.php \ - app/tests/Feature/Plan5/Projects/ProjectsActionsTest.php -git commit -m "feat(projects): Plan 5 Task 6 — destroy + sync + toggle-active + bulk endpoints" -``` - ---- - -## Task 7: Frontend router + nav + regions + ProjectCard - -**Files:** - -- Modify: `app/resources/js/router/index.ts` (+route `/projects`) -- Modify: `app/resources/js/components/layout/AppLayout.vue` (+nav item) -- Create: `app/resources/js/constants/regions.ts` -- Create: `app/resources/js/components/projects/ProjectCard.vue` -- Create: `app/resources/js/components/projects/ProjectCard.story.vue` -- Create: `app/tests/Frontend/ProjectCard.spec.ts` - -- [ ] **Step 1: Написать failing Vitest test** - -```typescript -// app/tests/Frontend/ProjectCard.spec.ts - -import { describe, it, expect } from 'vitest'; -import { mount } from '@vue/test-utils'; -import { createVuetify } from 'vuetify'; -import ProjectCard from '@/components/projects/ProjectCard.vue'; - -const vuetify = createVuetify(); -const baseProject = { - id: 1, name: 'Окна СПб', signal_type: 'site', signal_identifier: 'okna.ru', - daily_limit_target: 50, delivered_today: 32, is_active: true, - archived_at: null, sync_status: 'ok', -}; - -describe('ProjectCard', () => { - it('renders project name + signal_identifier', () => { - const wrapper = mount(ProjectCard, { - global: { plugins: [vuetify] }, - props: { project: baseProject, selected: false }, - }); - expect(wrapper.text()).toContain('Окна СПб'); - expect(wrapper.text()).toContain('okna.ru'); - }); - - it('shows progress percentage 32/50', () => { - const wrapper = mount(ProjectCard, { - global: { plugins: [vuetify] }, - props: { project: baseProject, selected: false }, - }); - expect(wrapper.text()).toMatch(/32.*50/); - }); - - it('emits select event on checkbox change', async () => { - const wrapper = mount(ProjectCard, { - global: { plugins: [vuetify] }, - props: { project: baseProject, selected: false }, - }); - await wrapper.find('[data-testid="card-select"]').trigger('change'); - expect(wrapper.emitted('toggle-select')).toBeTruthy(); - }); - - it('shows "На паузе" when is_active=false', () => { - const wrapper = mount(ProjectCard, { - global: { plugins: [vuetify] }, - props: { project: { ...baseProject, is_active: false }, selected: false }, - }); - expect(wrapper.text()).toContain('На паузе'); - }); -}); -``` - -- [ ] **Step 2: Run — expect FAIL** (ProjectCard не существует) - -```bash -cd app && npx vitest run tests/Frontend/ProjectCard.spec.ts -``` - -- [ ] **Step 3: Создать regions.ts** - -```typescript -// app/resources/js/constants/regions.ts - -export interface Region { - code: number; - name: string; -} - -// 89 регионов РФ + sentinel 0 -export const REGIONS: Region[] = [ - { code: 1, name: 'Республика Адыгея' }, - { code: 2, name: 'Республика Башкортостан' }, - { code: 3, name: 'Республика Бурятия' }, - // ... (полный список 89 регионов) - { code: 77, name: 'Москва' }, - { code: 78, name: 'Санкт-Петербург' }, - // полный список — это таблица, см. https://www.gosuslugi.ru/help/faq/passport/2 -]; - -// TODO имплементатору: заполнить полный массив 89 регионов из gosuslugi.ru. На MVP допустимо начать с 15-20 крупнейших, дополнить позже. -``` - -⚠️ **Важно:** имплементатор должен заполнить полный список 89 регионов перед merge. Это data file, не logic — копипаст из открытого источника. - -- [ ] **Step 4: Создать ProjectCard.vue** - -```vue - - - - - - -``` - -- [ ] **Step 5: Создать ProjectCard.story.vue** - -```vue - - - - -``` - -- [ ] **Step 6: Добавить route + nav-item** - -В `app/resources/js/router/index.ts` после `kanban` route: - -```typescript -{ - path: '/projects', - name: 'projects', - component: () => import('../views/ProjectsView.vue'), - meta: { layout: 'app', title: 'Проекты', requiresAuth: true }, -}, -``` - -⚠️ Создать пустой stub `ProjectsView.vue` для прохождения import (Task 8 заполнит): - -```vue - - -``` - -В `app/resources/js/components/layout/AppLayout.vue` найти секцию nav-tree «Работа» и добавить пункт «Проекты» (рядом с «Канбан»): - -```vue -Проекты -``` - -(Точный markup зависит от текущей nav-tree структуры. Сохранить стиль соседних пунктов.) - -- [ ] **Step 7: Run test — expect PASS** (4 Vitest tests) - -```bash -cd app && npx vitest run tests/Frontend/ProjectCard.spec.ts -``` - -- [ ] **Step 8: Commit** - -```bash -git add app/resources/js/router/index.ts \ - app/resources/js/components/layout/AppLayout.vue \ - app/resources/js/constants/regions.ts \ - app/resources/js/components/projects/ProjectCard.vue \ - app/resources/js/components/projects/ProjectCard.story.vue \ - app/resources/js/views/ProjectsView.vue \ - app/tests/Frontend/ProjectCard.spec.ts -git commit -m "feat(frontend): Plan 5 Task 7 — router + nav + regions + ProjectCard + story" -``` - ---- - -## Task 8: ProjectsView + projectsStore (no polling) - -**Files:** - -- Create: `app/resources/js/stores/projectsStore.ts` -- Modify: `app/resources/js/views/ProjectsView.vue` (заменить stub) -- Create: `app/resources/js/views/ProjectsView.story.vue` -- Create: `app/tests/Frontend/ProjectsView.spec.ts` -- Create: `app/tests/Frontend/projectsStore.spec.ts` - -- [ ] **Step 1: Написать failing tests** - -```typescript -// app/tests/Frontend/projectsStore.spec.ts - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { setActivePinia, createPinia } from 'pinia'; -import { useProjectsStore } from '@/stores/projectsStore'; -import axios from 'axios'; - -vi.mock('axios'); - -describe('projectsStore (no polling)', () => { - beforeEach(() => { - setActivePinia(createPinia()); - vi.clearAllMocks(); - }); - - it('fetches and stores list', async () => { - (axios.get as any).mockResolvedValue({ data: { data: [{ id: 1, name: 'X' }], meta: { total: 1, current_page: 1, per_page: 20 } } }); - const store = useProjectsStore(); - await store.fetch(); - expect(store.items.length).toBe(1); - expect(store.total).toBe(1); - }); - - it('passes filters to API', async () => { - (axios.get as any).mockResolvedValue({ data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } } }); - const store = useProjectsStore(); - store.filters.signal_type = 'site'; - store.filters.search = 'okna'; - await store.fetch(); - expect(axios.get).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ - params: expect.objectContaining({ signal_type: 'site', search: 'okna' }), - })); - }); - - it('create dispatches POST + refetches', async () => { - (axios.post as any).mockResolvedValue({ data: { data: { id: 2, name: 'New' } } }); - (axios.get as any).mockResolvedValue({ data: { data: [{ id: 2 }], meta: { total: 1, current_page: 1, per_page: 20 } } }); - const store = useProjectsStore(); - await store.create({ name: 'New', signal_type: 'site', signal_identifier: 'x.ru', daily_limit_target: 10, region_mask: 0, region_mode: 'include', delivery_days_mask: 127 }); - expect(axios.post).toHaveBeenCalled(); - }); - - it('toggleSelect adds and removes ids from selectedIds', () => { - const store = useProjectsStore(); - store.toggleSelect(1); - expect(store.selectedIds.has(1)).toBe(true); - store.toggleSelect(1); - expect(store.selectedIds.has(1)).toBe(false); - }); - - it('bulkAction sends array of ids and clears selection', async () => { - (axios.post as any).mockResolvedValue({ data: { updated: 2 } }); - (axios.get as any).mockResolvedValue({ data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } } }); - const store = useProjectsStore(); - store.selectedIds.add(1); store.selectedIds.add(2); - await store.bulkAction('pause'); - expect(axios.post).toHaveBeenCalledWith('/api/projects/bulk', { action: 'pause', ids: [1, 2] }); - expect(store.selectedIds.size).toBe(0); - }); -}); -``` - -```typescript -// app/tests/Frontend/ProjectsView.spec.ts - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { mount, flushPromises } from '@vue/test-utils'; -import { createPinia, setActivePinia } from 'pinia'; -import { createVuetify } from 'vuetify'; -import ProjectsView from '@/views/ProjectsView.vue'; -import axios from 'axios'; - -vi.mock('axios'); - -describe('ProjectsView', () => { - beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); - - it('shows empty state when no projects', async () => { - (axios.get as any).mockResolvedValue({ data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } } }); - const wrapper = mount(ProjectsView, { global: { plugins: [createVuetify()] } }); - await flushPromises(); - expect(wrapper.text()).toMatch(/нет проектов|empty/i); - }); - - it('renders card for each project', async () => { - (axios.get as any).mockResolvedValue({ data: { data: [{ id: 1, name: 'A', signal_type: 'site', signal_identifier: 'a.ru', daily_limit_target: 10, delivered_today: 0, is_active: true, archived_at: null, sync_status: 'ok' }], meta: { total: 1, current_page: 1, per_page: 20 } } }); - const wrapper = mount(ProjectsView, { global: { plugins: [createVuetify()] } }); - await flushPromises(); - expect(wrapper.text()).toContain('A'); - }); - - it('filter by signal_type refetches', async () => { - (axios.get as any).mockResolvedValue({ data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } } }); - const wrapper = mount(ProjectsView, { global: { plugins: [createVuetify()] } }); - await flushPromises(); - vi.clearAllMocks(); - (axios.get as any).mockResolvedValue({ data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } } }); - // simulate filter change — depends on UI; example: find v-select and emit - await wrapper.findComponent({ name: 'VSelect' })?.vm.$emit('update:model-value', 'site'); - await flushPromises(); - expect(axios.get).toHaveBeenCalled(); - }); - - it('shows BulkActionsBar when at least 1 selected', async () => { - (axios.get as any).mockResolvedValue({ data: { data: [{ id: 1, name: 'A', signal_type: 'site', signal_identifier: 'a.ru', daily_limit_target: 10, delivered_today: 0, is_active: true, archived_at: null, sync_status: 'ok' }], meta: { total: 1, current_page: 1, per_page: 20 } } }); - const wrapper = mount(ProjectsView, { global: { plugins: [createVuetify()] } }); - await flushPromises(); - // simulate selection event from ProjectCard - await wrapper.findComponent({ name: 'ProjectCard' })?.vm.$emit('toggle-select', 1); - await wrapper.vm.$nextTick(); - expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(true); - }); -}); -``` - -- [ ] **Step 2: Run — expect FAIL** (нет store, ProjectsView — stub) - -- [ ] **Step 3: Создать projectsStore.ts** - -```typescript -// app/resources/js/stores/projectsStore.ts - -import { defineStore } from 'pinia'; -import { ref, reactive } from 'vue'; -import axios from 'axios'; - -export interface Project { - id: number; - name: string; - signal_type: 'site' | 'call' | 'sms'; - signal_identifier?: string | null; - sms_senders?: string[] | null; - sms_keyword?: string | null; - daily_limit_target: number; - delivered_today: number; - delivered_in_month?: number; - is_active: boolean; - archived_at: string | null; - region_mask: number; - region_mode: string; - delivery_days_mask: number; - sync_status: 'ok' | 'pending' | 'failed'; - last_synced_at?: string | null; -} - -export const useProjectsStore = defineStore('projects', () => { - const items = ref([]); - const total = ref(0); - const filters = reactive({ signal_type: '', status: '', search: '', page: 1, per_page: 20 }); - const selectedIds = ref>(new Set()); - const pendingIds = ref>(new Set()); - const loading = ref(false); - - async function fetch() { - loading.value = true; - try { - const params: Record = { page: filters.page, per_page: filters.per_page }; - if (filters.signal_type) params.signal_type = filters.signal_type; - if (filters.status) params.status = filters.status; - if (filters.search) params.search = filters.search; - const { data } = await axios.get('/api/projects', { params }); - items.value = data.data; - total.value = data.meta.total; - } finally { - loading.value = false; - } - } - - async function create(payload: Partial) { - const { data } = await axios.post('/api/projects', payload); - pendingIds.value.add(data.data.id); - await fetch(); - return data.data; - } - - async function update(id: number, payload: Partial) { - const { data } = await axios.patch(`/api/projects/${id}`, payload); - await fetch(); - return data.data; - } - - async function archive(id: number) { - await axios.delete(`/api/projects/${id}`); - await fetch(); - } - - async function syncNow(id: number) { - await axios.post(`/api/projects/${id}/sync`); - pendingIds.value.add(id); - await fetch(); - } - - async function toggleActive(project: Project) { - await axios.patch(`/api/projects/${project.id}/toggle-active`, { is_active: !project.is_active }); - await fetch(); - } - - function toggleSelect(id: number) { - if (selectedIds.value.has(id)) { - selectedIds.value.delete(id); - } else { - selectedIds.value.add(id); - } - } - - function clearSelection() { - selectedIds.value.clear(); - } - - async function bulkAction(action: 'pause' | 'resume' | 'archive') { - const ids = Array.from(selectedIds.value); - if (!ids.length) return; - await axios.post('/api/projects/bulk', { action, ids }); - clearSelection(); - await fetch(); - } - - return { items, total, filters, selectedIds, pendingIds, loading, - fetch, create, update, archive, syncNow, toggleActive, - toggleSelect, clearSelection, bulkAction }; -}); -``` - -- [ ] **Step 4: Заменить ProjectsView stub на полную версию** - -```vue - - - - - - -``` - -⚠️ Так как NewProjectDialog / EditProjectDialog / BulkActionsBar — следующие task'и, временно создать пустые stub-компоненты с защитой ``: - -```vue - - - -``` - -Аналогично `EditProjectDialog.vue` и `BulkActionsBar.vue` — пустые stub'ы. - -- [ ] **Step 5: Создать ProjectsView.story.vue** - -```vue - - - -``` - -- [ ] **Step 6: Run tests — expect PASS** (4 ProjectsView + 5 projectsStore) - -```bash -cd app && npx vitest run tests/Frontend/ProjectsView.spec.ts tests/Frontend/projectsStore.spec.ts -``` - -- [ ] **Step 7: Commit** - -```bash -git add app/resources/js/stores/projectsStore.ts \ - app/resources/js/views/ProjectsView.vue \ - app/resources/js/views/ProjectsView.story.vue \ - app/resources/js/views/projects/NewProjectDialog.vue \ - app/resources/js/views/projects/EditProjectDialog.vue \ - app/resources/js/components/projects/BulkActionsBar.vue \ - app/tests/Frontend/ProjectsView.spec.ts \ - app/tests/Frontend/projectsStore.spec.ts -git commit -m "feat(frontend): Plan 5 Task 8 — ProjectsView + projectsStore (no polling) + 9 tests" -``` - ---- - -## Task 9: NewProjectDialog (3 таба) - -**Files:** - -- Modify: `app/resources/js/views/projects/NewProjectDialog.vue` (заменить stub) -- Create: `app/resources/js/views/projects/NewProjectDialog.story.vue` -- Create: `app/tests/Frontend/NewProjectDialog.spec.ts` - -- [ ] **Step 1: Написать failing test** - -```typescript -// app/tests/Frontend/NewProjectDialog.spec.ts - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { mount, flushPromises } from '@vue/test-utils'; -import { createPinia, setActivePinia } from 'pinia'; -import { createVuetify } from 'vuetify'; -import NewProjectDialog from '@/views/projects/NewProjectDialog.vue'; -import axios from 'axios'; - -vi.mock('axios'); - -describe('NewProjectDialog', () => { - beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); - - it('renders 3 tabs: Сайт / Звонок / СМС', async () => { - const wrapper = mount(NewProjectDialog, { - global: { plugins: [createVuetify()] }, - props: { modelValue: true, mode: 'create' }, - }); - await flushPromises(); - expect(wrapper.text()).toContain('Сайт'); - expect(wrapper.text()).toContain('Звонок'); - expect(wrapper.text()).toContain('СМС'); - }); - - it('switching to SMS tab shows sms_senders field', async () => { - const wrapper = mount(NewProjectDialog, { - global: { plugins: [createVuetify()] }, - props: { modelValue: true, mode: 'create' }, - }); - await flushPromises(); - // simulate tab change - await wrapper.findComponent({ name: 'VTabs' })?.vm.$emit('update:model-value', 'sms'); - await wrapper.vm.$nextTick(); - expect(wrapper.text()).toMatch(/Отправители|senders/i); - }); - - it('validation: site domain required', async () => { - const wrapper = mount(NewProjectDialog, { - global: { plugins: [createVuetify()] }, - props: { modelValue: true, mode: 'create' }, - }); - await flushPromises(); - // submit empty form - await wrapper.find('[data-testid="submit-btn"]').trigger('click'); - await flushPromises(); - expect(axios.post).not.toHaveBeenCalled(); // блокируется валидацией - }); - - it('submits valid site project to POST /api/projects', async () => { - (axios.post as any).mockResolvedValue({ data: { data: { id: 1 } } }); - const wrapper = mount(NewProjectDialog, { - global: { plugins: [createVuetify()] }, - props: { modelValue: true, mode: 'create' }, - }); - await flushPromises(); - // fill name + identifier through ref/text-field — depends on actual markup - // ... - // на CI этот тест может быть skipped до уточнения деталей формы - }); - - it('emits saved event after successful POST', async () => { - (axios.post as any).mockResolvedValue({ data: { data: { id: 1 } } }); - const wrapper = mount(NewProjectDialog, { - global: { plugins: [createVuetify()] }, - props: { modelValue: true, mode: 'create' }, - }); - // similar to above — может быть test-stub до полной реализации - }); -}); -``` - -⚠️ Тесты Vitest для NewProjectDialog могут быть частично stub'нуты — детали Vuetify-формы трудно тестировать без полного render. Использовать `findComponent({ name: 'VTextField' })` + emit, или skipped tests с TODO для пост-MVP детализации. - -- [ ] **Step 2: Run — expect FAIL** (stub-компонент) - -- [ ] **Step 3: Реализовать NewProjectDialog.vue** - -```vue - - - - -``` - -⚠️ **TODO для имплементатора:** `region_mask` 32-bit лимит — для 89 регионов РФ требуется `bigint` / array колонка. На MVP используем только регионы 1-31, для остальных пользователь не увидит чекбокса. Расширение — Plan 6 (schema delta + bigint mask). - -- [ ] **Step 4: Создать NewProjectDialog.story.vue** - -```vue - - - -``` - -- [ ] **Step 5: Run test — expect PASS** (5 Vitest tests; некоторые могут быть skipped) - -```bash -cd app && npx vitest run tests/Frontend/NewProjectDialog.spec.ts -``` - -- [ ] **Step 6: Commit** - -```bash -git add app/resources/js/views/projects/NewProjectDialog.vue \ - app/resources/js/views/projects/NewProjectDialog.story.vue \ - app/tests/Frontend/NewProjectDialog.spec.ts -git commit -m "feat(frontend): Plan 5 Task 9 — NewProjectDialog (3 tabs Site/Call/SMS) + story" -``` - ---- - -## Task 10: EditProjectDialog wrapper + BulkActionsBar - -**Files:** - -- Modify: `app/resources/js/views/projects/EditProjectDialog.vue` (заменить stub) -- Modify: `app/resources/js/components/projects/BulkActionsBar.vue` (заменить stub) -- Create: `app/resources/js/components/projects/BulkActionsBar.story.vue` -- Create: `app/tests/Frontend/EditProjectDialog.spec.ts` -- Create: `app/tests/Frontend/BulkActionsBar.spec.ts` - -- [ ] **Step 1: Написать failing tests** - -```typescript -// app/tests/Frontend/EditProjectDialog.spec.ts - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { mount, flushPromises } from '@vue/test-utils'; -import { createPinia, setActivePinia } from 'pinia'; -import { createVuetify } from 'vuetify'; -import EditProjectDialog from '@/views/projects/EditProjectDialog.vue'; -import axios from 'axios'; - -vi.mock('axios'); - -describe('EditProjectDialog', () => { - beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); - - it('prefills form from project prop', async () => { - const wrapper = mount(EditProjectDialog, { - global: { plugins: [createVuetify()] }, - props: { modelValue: true, project: { id: 1, name: 'X', signal_type: 'site', signal_identifier: 'x.ru', - daily_limit_target: 10, region_mask: 0, region_mode: 'include', delivery_days_mask: 127 } }, - }); - await flushPromises(); - expect(wrapper.text()).toContain('Редактирование'); - }); - - it('PATCH on submit', async () => { - (axios.patch as any).mockResolvedValue({ data: { data: { id: 1 } } }); - const wrapper = mount(EditProjectDialog, { - global: { plugins: [createVuetify()] }, - props: { modelValue: true, project: { id: 1, name: 'X', signal_type: 'site', signal_identifier: 'x.ru', daily_limit_target: 10, region_mask: 0, region_mode: 'include', delivery_days_mask: 127 } }, - }); - await flushPromises(); - await wrapper.find('[data-testid="submit-btn"]').trigger('click'); - await flushPromises(); - expect(axios.patch).toHaveBeenCalled(); - }); - - it('signal_type tabs disabled in edit mode', async () => { - const wrapper = mount(EditProjectDialog, { - global: { plugins: [createVuetify()] }, - props: { modelValue: true, project: { id: 1, name: 'X', signal_type: 'site', signal_identifier: 'x.ru', daily_limit_target: 10, region_mask: 0, region_mode: 'include', delivery_days_mask: 127 } }, - }); - await flushPromises(); - // VTabs disabled prop - expect(wrapper.findComponent({ name: 'VTabs' }).props('disabled')).toBe(true); - }); -}); -``` - -```typescript -// app/tests/Frontend/BulkActionsBar.spec.ts - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { mount } from '@vue/test-utils'; -import { createPinia, setActivePinia } from 'pinia'; -import { createVuetify } from 'vuetify'; -import BulkActionsBar from '@/components/projects/BulkActionsBar.vue'; -import { useProjectsStore } from '@/stores/projectsStore'; - -describe('BulkActionsBar', () => { - beforeEach(() => { setActivePinia(createPinia()); }); - - it('shows count of selected', () => { - const store = useProjectsStore(); - store.selectedIds.add(1); store.selectedIds.add(2); - const wrapper = mount(BulkActionsBar, { global: { plugins: [createVuetify()] } }); - expect(wrapper.text()).toContain('2'); - }); - - it('clicking Pause triggers store.bulkAction("pause") after confirm', async () => { - const store = useProjectsStore(); - store.selectedIds.add(1); - const spy = vi.spyOn(store, 'bulkAction').mockResolvedValue(); - window.confirm = vi.fn(() => true); - const wrapper = mount(BulkActionsBar, { global: { plugins: [createVuetify()] } }); - await wrapper.find('[data-testid="bulk-pause"]').trigger('click'); - expect(spy).toHaveBeenCalledWith('pause'); - }); - - it('cancel confirm — bulkAction NOT called', async () => { - const store = useProjectsStore(); - store.selectedIds.add(1); - const spy = vi.spyOn(store, 'bulkAction').mockResolvedValue(); - window.confirm = vi.fn(() => false); - const wrapper = mount(BulkActionsBar, { global: { plugins: [createVuetify()] } }); - await wrapper.find('[data-testid="bulk-pause"]').trigger('click'); - expect(spy).not.toHaveBeenCalled(); - }); - - it('Clear-selection button calls store.clearSelection', async () => { - const store = useProjectsStore(); - store.selectedIds.add(1); - const wrapper = mount(BulkActionsBar, { global: { plugins: [createVuetify()] } }); - await wrapper.find('[data-testid="bulk-clear"]').trigger('click'); - expect(store.selectedIds.size).toBe(0); - }); -}); -``` - -- [ ] **Step 2: Run — expect FAIL** - -- [ ] **Step 3: Реализовать EditProjectDialog.vue (re-use NewProjectDialog)** - -```vue - - - - -``` - -- [ ] **Step 4: Реализовать BulkActionsBar.vue** - -```vue - - - - - - -``` - -- [ ] **Step 5: Создать BulkActionsBar.story.vue** - -```vue - - - -``` - -- [ ] **Step 6: Run tests — expect PASS** (3 EditDialog + 4 BulkBar) - -```bash -cd app && npx vitest run tests/Frontend/EditProjectDialog.spec.ts tests/Frontend/BulkActionsBar.spec.ts -``` - -- [ ] **Step 7: Commit** - -```bash -git add app/resources/js/views/projects/EditProjectDialog.vue \ - app/resources/js/components/projects/BulkActionsBar.vue \ - app/resources/js/components/projects/BulkActionsBar.story.vue \ - app/tests/Frontend/EditProjectDialog.spec.ts \ - app/tests/Frontend/BulkActionsBar.spec.ts -git commit -m "feat(frontend): Plan 5 Task 10 — EditProjectDialog wrapper + BulkActionsBar + 7 tests" -``` - ---- - -## Task 11: Polling integration в projectsStore - -**Files:** - -- Modify: `app/resources/js/stores/projectsStore.ts` (+polling logic) -- Modify: `app/tests/Frontend/projectsStore.spec.ts` (+3 polling tests) - -- [ ] **Step 1: Написать failing tests с fake-timers** - -Добавить в `tests/Frontend/projectsStore.spec.ts`: - -```typescript -describe('projectsStore (polling)', () => { - beforeEach(() => { - setActivePinia(createPinia()); - vi.useFakeTimers(); - vi.clearAllMocks(); - }); - - afterEach(() => { vi.useRealTimers(); }); - - it('starts polling when pendingIds has items', async () => { - (axios.get as any).mockResolvedValue({ data: { data: [ - { id: 1, sync_status: 'pending', name: 'X', signal_type: 'site', signal_identifier: 'x.ru', daily_limit_target: 10, delivered_today: 0, is_active: true, archived_at: null, region_mask: 0, region_mode: 'include', delivery_days_mask: 127 }, - ] } }); - const store = useProjectsStore(); - store.pendingIds.add(1); - store.startPolling(); - expect(axios.get).not.toHaveBeenCalled(); // ещё не сработал setTimeout - await vi.advanceTimersByTimeAsync(5000); - expect(axios.get).toHaveBeenCalled(); - }); - - it('stops polling when pendingIds becomes empty (sync_status ok)', async () => { - (axios.get as any).mockResolvedValue({ data: { data: [ - { id: 1, sync_status: 'ok', name: 'X', signal_type: 'site', signal_identifier: 'x.ru', daily_limit_target: 10, delivered_today: 0, is_active: true, archived_at: null, region_mask: 0, region_mode: 'include', delivery_days_mask: 127 }, - ] } }); - const store = useProjectsStore(); - store.pendingIds.add(1); - store.startPolling(); - await vi.advanceTimersByTimeAsync(5000); - expect(store.pendingIds.size).toBe(0); - vi.clearAllMocks(); - await vi.advanceTimersByTimeAsync(5000); - expect(axios.get).not.toHaveBeenCalled(); // polling остановился - }); - - it('exponential backoff on error', async () => { - (axios.get as any).mockRejectedValue(new Error('network')); - const store = useProjectsStore(); - store.pendingIds.add(1); - store.startPolling(); - await vi.advanceTimersByTimeAsync(5000); // 1-я попытка - expect(axios.get).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(10000); // 2-я попытка (после backoff *2) - expect(axios.get).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(20000); // 3-я - expect(axios.get).toHaveBeenCalledTimes(3); - }); -}); -``` - -- [ ] **Step 2: Run — expect FAIL** (startPolling не существует) - -- [ ] **Step 3: Расширить projectsStore.ts** - -Добавить в `defineStore`: - -```typescript -let pollTimeout: number | null = null; -let currentDelay = 5000; -const DELAY_OK = 5000; -const DELAY_MAX = 30000; - -function startPolling() { - if (pollTimeout !== null) return; - scheduleNext(); -} - -function scheduleNext() { - pollTimeout = window.setTimeout(async () => { - pollTimeout = null; - if (pendingIds.value.size === 0) { - currentDelay = DELAY_OK; - return; - } - try { - const ids = Array.from(pendingIds.value).join(','); - const { data } = await axios.get('/api/projects', { params: { ids } }); - for (const project of data.data as Project[]) { - const idx = items.value.findIndex(i => i.id === project.id); - if (idx !== -1) items.value[idx] = project; - if (project.sync_status === 'ok' || project.sync_status === 'failed') { - pendingIds.value.delete(project.id); - } - } - currentDelay = DELAY_OK; - } catch (e) { - currentDelay = Math.min(currentDelay * 2, DELAY_MAX); - } - if (pendingIds.value.size > 0) { - scheduleNext(); - } - }, currentDelay); -} - -function stopPolling() { - if (pollTimeout !== null) { - window.clearTimeout(pollTimeout); - pollTimeout = null; - } - currentDelay = DELAY_OK; -} -``` - -Экспортировать в return: `startPolling, stopPolling`. - -- [ ] **Step 4: Подключить polling в ProjectsView** - -В `ProjectsView.vue` ` -``` - -- [ ] **Step 2: Write failing integration test** - -```typescript -// app/vite-plugins/dev-indices/__tests__/integration.test.ts -import { describe, it, expect, beforeEach } from 'vitest'; -import { mkdtempSync, readFileSync, rmSync, copyFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { devIndicesPlugin } from '../index'; - -const FIXTURE = resolve(__dirname, 'fixtures/sample.vue'); - -function runTransform(filePath: string, manifestPath: string) { - const plugin = devIndicesPlugin({ - manifestPath, - appRoot: resolve(__dirname, '..', '..', '..'), - enabled: true, - }); - // buildStart loads existing manifest (or createEmpty() if missing) - if (typeof plugin.buildStart === 'function') (plugin.buildStart as Function).call({}); - const source = readFileSync(filePath, 'utf8'); - const result = (plugin.transform as Function).call({}, source, filePath); - // buildEnd flushes manifest to disk (in real Vite, called once at end of build) - if (typeof plugin.buildEnd === 'function') (plugin.buildEnd as Function).call({}); - return result?.code ?? result; -} - -describe('vite-plugin-dev-indices integration', () => { - let dir: string; - let manifestPath: string; - let workingFixture: string; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'dx-int-')); - manifestPath = join(dir, 'dev-indices.json'); - workingFixture = join(dir, 'sample.vue'); - copyFileSync(FIXTURE, workingFixture); - }); - - it('injects data-dx attributes into every element', () => { - const code = runTransform(workingFixture, manifestPath); - expect(code).toMatch(/
/); - expect(code).toMatch(//); - expect(code).toMatch(//); - expect(code).toMatch(//); - expect(code).toMatch(//); - }); - - it('writes manifest with one entry per element', () => { - runTransform(workingFixture, manifestPath); - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - // 5 elements in fixture - expect(Object.keys(manifest.entries)).toHaveLength(5); - expect(manifest.lastId).toBe(5); - }); - - it('second run on unchanged file produces identical IDs', () => { - const code1 = runTransform(workingFixture, manifestPath); - const code2 = runTransform(workingFixture, manifestPath); - expect(code1).toBe(code2); - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - expect(manifest.lastId).toBe(5); - }); - - it('returns null for non-.vue files', () => { - const plugin = devIndicesPlugin({ manifestPath, appRoot: dir, enabled: true }); - expect((plugin.transform as Function).call({}, '// js', '/foo/bar.ts')).toBeNull(); - }); - - it('returns null when disabled', () => { - const plugin = devIndicesPlugin({ manifestPath, appRoot: dir, enabled: false }); - const source = readFileSync(workingFixture, 'utf8'); - expect((plugin.transform as Function).call({}, source, workingFixture)).toBeNull(); - }); - - afterEach(() => rmSync(dir, { recursive: true, force: true })); -}); -``` - -Note: add `import { afterEach } from 'vitest';` at the top. - -- [ ] **Step 3: Run test, verify failure** - -Run: `cd app && npx vitest run vite-plugins/dev-indices/__tests__/integration.test.ts` -Expected: FAIL with `Cannot find module '../index'`. - -- [ ] **Step 4: Implement index.ts (plugin core)** - -```typescript -// app/vite-plugins/dev-indices/index.ts -import type { Plugin } from 'vite'; -import { parse } from '@vue/compiler-sfc'; -import { type ElementNode, NodeTypes } from '@vue/compiler-core'; -import MagicString from 'magic-string'; -import { relative, resolve } from 'node:path'; - -import type { Manifest, ManifestEntry } from './types'; -import { - createEmpty, - loadManifest, - saveManifest, - findBySignature, - addEntry, -} from './manifest'; -import { - computeSignature, - type SignatureNode, - type SignatureNodeProp, - type SignatureNodeChild, -} from './signature'; - -export interface DevIndicesPluginOptions { - manifestPath: string; - appRoot: string; - enabled: boolean; -} - -interface ElementWalkContext { - ancestorChain: string[]; - sameTagOrdinalStack: Map[]; -} - -function nodeToSignatureNode(el: ElementNode): SignatureNode { - const props: SignatureNodeProp[] = el.props.map((p): SignatureNodeProp => { - if (p.type === NodeTypes.ATTRIBUTE) { - return { type: 'attribute', name: p.name, value: p.value?.content }; - } - // DIRECTIVE node - return { - type: 'directive', - name: p.name, - arg: 'arg' in p && p.arg && 'content' in p.arg ? p.arg.content : undefined, - exp: 'exp' in p && p.exp && 'content' in p.exp ? p.exp.content : undefined, - }; - }); - const children: SignatureNodeChild[] = el.children.map((c): SignatureNodeChild => { - if (c.type === NodeTypes.TEXT) return { type: 'text', content: c.content }; - if (c.type === NodeTypes.INTERPOLATION) return { type: 'interpolation' }; - if (c.type === NodeTypes.ELEMENT) return { type: 'element' }; - if (c.type === NodeTypes.COMMENT) return { type: 'comment' }; - return { type: 'element' }; - }); - return { tag: el.tag, props, children }; -} - -function elementOpenTagEndOffset(el: ElementNode, templateContent: string, templateOffset: number): number { - // Find the position just before the closing `>` of the opening tag - // el.loc.start.offset is start of `` relative to templateContent - const startAbs = templateOffset + el.loc.start.offset; - // Walk forward in source to find unescaped `>` (skipping inside quotes) - let i = startAbs; - let inQuote: string | null = null; - while (i < templateOffset + templateContent.length) { - const ch = templateContent[i - templateOffset]; - if (inQuote) { - if (ch === inQuote) inQuote = null; - } else if (ch === '"' || ch === "'") { - inQuote = ch; - } else if (ch === '>') { - // self-closing: `/>` — insert before the `/` - if (templateContent[i - templateOffset - 1] === '/') return i - 1; - return i; - } - i++; - } - return -1; -} - -export function devIndicesPlugin(options: DevIndicesPluginOptions): Plugin { - const { manifestPath, appRoot, enabled } = options; - let manifest: Manifest = createEmpty(); - let dirty = false; - - return { - name: 'vite-plugin-dev-indices', - - buildStart() { - if (!enabled) return; - manifest = loadManifest(manifestPath); - }, - - transform(code, id) { - if (!enabled) return null; - if (!id.endsWith('.vue')) return null; - // Skip query-suffixed Vue requests (e.g. ?type=style) - if (id.includes('?')) return null; - - const { descriptor } = parse(code, { filename: id }); - if (!descriptor.template) return null; - - const tplContent = descriptor.template.content; - const tplOffset = descriptor.template.loc.start.offset; - const ast = descriptor.template.ast; - if (!ast) return null; - - const s = new MagicString(code); - const relPath = relative(appRoot, id).replace(/\\/g, '/'); - - const walk = (node: ElementNode | (typeof ast), chain: string[], siblingOrdinals: Map) => { - const children = ('children' in node ? node.children : []) as ElementNode['children']; - const localOrdinals = new Map(); - for (const child of children) { - if (child.type !== NodeTypes.ELEMENT) continue; - const tag = child.tag; - const ord = localOrdinals.get(tag) ?? 0; - localOrdinals.set(tag, ord + 1); - - const sig = computeSignature(nodeToSignatureNode(child), { - filePath: id, - appRoot, - ancestorChain: chain, - sameTagOrdinal: ord, - }); - - let assignedId = findBySignature(manifest, sig); - if (assignedId == null) { - const entry: ManifestEntry = { - file: relPath, - line: child.loc.start.line, - tag, - parentChain: [...chain], - signature: sig, - text: - child.children - .filter((c) => c.type === NodeTypes.TEXT) - .map((c) => (c as { content: string }).content) - .join(' ') - .trim() - .slice(0, 24) || null, - key: null, - ref: null, - createdAt: new Date().toISOString(), - }; - assignedId = addEntry(manifest, entry); - dirty = true; - } - - // Inject `data-dx="N"` into opening tag - const insertPos = elementOpenTagEndOffset(child, tplContent, tplOffset); - if (insertPos >= 0) { - s.appendLeft(insertPos, ` data-dx="${assignedId}"`); - } - - walk(child, [...chain, tag], localOrdinals); - } - }; - - // Top-level: declaring component name from file path - const declaringName = id.split(/[\\/]/).pop()!.replace(/\.vue$/, ''); - walk(ast, [declaringName], new Map()); - - // Flush manifest after every transform (idempotent if not dirty) - if (dirty) { - saveManifest(manifestPath, manifest); - dirty = false; - } - - return { - code: s.toString(), - map: s.generateMap({ hires: true, source: id }), - }; - }, - - buildEnd() { - if (!enabled) return; - if (dirty) { - saveManifest(manifestPath, manifest); - dirty = false; - } - }, - }; -} - -export default devIndicesPlugin; -``` - -> **Note on Vue parser API surface.** The exact import paths from `@vue/compiler-sfc` / `@vue/compiler-core` may shift across minor versions. If the test fails with a "module not found" or "NodeTypes undefined" error, the engineer should: -> -> 1. Run `node -e "console.log(Object.keys(require('@vue/compiler-sfc')))"` to inspect available exports -> 2. `NodeTypes` may need to come from `@vue/compiler-core` directly or via `@vue/compiler-dom` -> 3. `descriptor.template.ast` exists from Vue 3.4+; verify with `console.log(typeof descriptor.template.ast)` first -> -> Adjust imports inline. The algorithm itself doesn't change. - -- [ ] **Step 5: Run test, verify failure first (fixture missing or API issue)** - -Run: `cd app && npx vitest run vite-plugins/dev-indices/__tests__/integration.test.ts` -Expected: integration FAIL initially with parser-API issues; iterate until PASS. - -If tests fail due to `magic-string` not installed: `cd app && npm install --save-dev magic-string` (Vite already ships with it transitively, but explicit dep is safer). - -- [ ] **Step 6: Iterate until tests pass** - -Common adjustments: - -- Use `import { NodeTypes } from '@vue/compiler-dom'` if compiler-core doesn't re-export -- Use `descriptor.template.ast.children` directly if walk function shape is off -- Add `console.log(descriptor.template.ast)` once to inspect actual structure, then remove - -- [ ] **Step 7: Run all dev-indices tests** - -Run: `cd app && npx vitest run vite-plugins/dev-indices/` -Expected: all PASS. - -- [ ] **Step 8: Commit** - -```bash -git add app/vite-plugins/dev-indices/index.ts \ - app/vite-plugins/dev-indices/__tests__/integration.test.ts \ - app/vite-plugins/dev-indices/__tests__/fixtures/sample.vue \ - app/package.json app/package-lock.json -git commit -m "feat(dev-indices): Vite plugin core (transform + magic-string injection)" -``` - ---- - -## Task 4: Wire plugin into vite.config.ts under dev-guard - -**Files:** - -- Modify: `app/vite.config.ts` - -- [ ] **Step 1: Read current vite.config.ts to determine integration point** - -Run: `cat app/vite.config.ts` — note where plugins array lives and existing plugin pattern. - -- [ ] **Step 2: Edit vite.config.ts to register plugin** - -Add import near top: - -```typescript -import { devIndicesPlugin } from './vite-plugins/dev-indices'; -``` - -Inside `defineConfig({ ... })`, locate the `plugins: [...]` array and insert (keep this addition first or near `vue()`): - -```typescript -const devIndicesEnabled = - process.env.NODE_ENV !== 'production' && process.env.VITE_DEV_INDICES !== '0'; - -export default defineConfig({ - // ... existing config ... - plugins: [ - devIndicesPlugin({ - manifestPath: resolve(__dirname, 'dev-indices.json'), - appRoot: __dirname, - enabled: devIndicesEnabled, - }), - // ... existing plugins (vue(), vuetify(), etc) ... - ], -}); -``` - -(If `resolve` and `__dirname` aren't already imported, add `import { resolve } from 'node:path';` at top.) - -- [ ] **Step 3: Smoke-test dev server** - -Run: `cd app && npm run dev` - -Expected: - -- Server starts without errors -- Open browser to `/login`, view source → `
` / `