diff --git a/cspell-words.txt b/cspell-words.txt index 2d4babd5..97df4031 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1436,3 +1436,6 @@ evals волатильны незакоммиченное бандл + +# План «Сделки drawer + редактирование источника» (2026-05-18) +табах diff --git a/docs/superpowers/plans/2026-05-18-deals-drawer-and-project-source-edit.md b/docs/superpowers/plans/2026-05-18-deals-drawer-and-project-source-edit.md new file mode 100644 index 00000000..f8c6a7d4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-deals-drawer-and-project-source-edit.md @@ -0,0 +1,885 @@ +# Deals drawer + project source edit — 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:** Привести drawer-«легенду» сделки и карточку проекта к запросу заказчика 18.05.2026 — статус-picker, корректные параметры (Тип/Источник), selected-driven видимость drawer/bulk-полосы, редактирование источника проекта. + +**Architecture:** 5 атомарных задач (1 коммит = 1 task). Frontend-only задачи 1-4 (Vue/TS). Задача 5 расширяет backend (UpdateProjectRequest + ProjectController) + UI ProjectDetailsDrawer. TDD per task: failing test → minimal impl → vitest/pest → commit. + +**Tech Stack:** Vue 3 + Vuetify 3 + Pinia, Laravel 13 + Pest 4, axios + ApiClient. + +**Источник истины** для решений: AskUserQuestion ответы 18.05.2026: + +- п.1: «при выборе 1 сделки она не нужна, нужна только легенда справа» +- п.2: «при выборе 2-х и более легенда не нужна а полоса нужна» +- п.3: статус в drawer кликабельный, dropdown статусов +- п.4: убрать «Менеджер»/«Не назначен» +- п.5: B-префикс уже убран (commit `36ea9cd`) +- п.6: формат «отправитель + (ключевое слово как в карточке создания)» = `signal_identifier` для site/call; для sms — `sms_senders[0]` + `(${sms_keyword})` если есть +- п.7: «Тип» (Сайт/Звонок/СМС) вместо «Менеджер» +- п.8: подпись «Источник» над полями на 3 табах NewProjectDialog +- п.9: редактировать источник **только в карточке проекта** (ProjectDetailsDrawer на /projects); в drawer сделки источник read-only + +--- + +## File Structure + +| Файл | Что делает | +|---|---| +| `app/resources/js/views/DealsView.vue` | Selected-driven: drawer hidden при ≥2 selected; auto-open при selected=1 | +| `app/resources/js/components/deals/DealDetailHero.vue` | StatusPill → inline statuspicker (`v-menu` со списком статусов) | +| `app/resources/js/components/deals/DealDetailBody.vue` | Убрать «Менеджер», добавить «Тип» + «Источник» (read-only) | +| `app/resources/js/composables/mockDeals.ts` | +поля projectSignalType / projectSignalIdentifier / projectSmsSenders / projectSmsKeyword | +| `app/resources/js/composables/dealsApiMapper.ts` | Маппинг новых API-полей | +| `app/resources/js/api/deals.ts` | Расширить ApiDeal интерфейс новыми полями | +| `app/app/Http/Controllers/Api/DealController.php` | Eager-load + отдавать новые поля проекта в payload | +| `app/resources/js/views/projects/NewProjectDialog.vue` | Подпись «Источник» над полями на 3 табах | +| `app/resources/js/components/projects/ProjectDetailsDrawer.vue` | Добавить редактирование signal_identifier (site/call) + sms_senders/keyword (sms) | +| `app/app/Http/Requests/UpdateProjectRequest.php` | +правила валидации signal_identifier по signal_type проекта | +| `app/app/Http/Controllers/Api/ProjectController.php` | update() — пропустить signal_identifier в Project::update | +| `app/resources/js/stores/projectsStore.ts` | Project type — поле signal_identifier ОК; проверить только | + +--- + +## Task 1: Selected-driven drawer visibility (пп. 1+2) + +**Files:** + +- Modify: `app/resources/js/views/DealsView.vue` (полная логика panelOpen ↔ selected.length) +- Test: `app/tests/Frontend/DealsView.spec.ts` (расширить существующий) + +**Логика:** + +- `selected.length === 0` → row-click открывает drawer (как сейчас) +- `selected.length === 1` → drawer **авто-открыт для этой сделки**, bulk-полоса **скрыта** +- `selected.length >= 2` → drawer **закрыт**, bulk-полоса видна + +DealsBulkBar уже показывается только при `selectedCount > 0` (нужно перепроверить — возможно показать при `>= 2` only). + +- [ ] **Step 1: Failing test для авто-открытия при selected=1** + +В `app/tests/Frontend/DealsView.spec.ts` добавить: + +```ts +it('при selected=1 drawer авто-открывается на выбранной сделке, bulk-полоса скрыта', async () => { + const w = mount(DealsView, { global: { plugins: [vuetify, createPinia()] } }); + await flushPromises(); + w.vm.dealsState.push({ id: 42, name: 'X', phone: '+79991234567', statusSlug: 'new', project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 0 } as never); + w.vm.selected = [42]; + await nextTick(); + expect(w.vm.panelOpen).toBe(true); + expect(w.vm.selectedDeal?.id).toBe(42); +}); + +it('при selected>=2 drawer закрывается', async () => { + const w = mount(DealsView, { global: { plugins: [vuetify, createPinia()] } }); + await flushPromises(); + w.vm.dealsState.push({ id: 42, name: 'X', phone: '+1', statusSlug: 'new', project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 0 } as never); + w.vm.dealsState.push({ id: 43, name: 'Y', phone: '+2', statusSlug: 'new', project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 0 } as never); + w.vm.panelOpen = true; + w.vm.selectedDeal = w.vm.dealsState[0]; + w.vm.selected = [42, 43]; + await nextTick(); + expect(w.vm.panelOpen).toBe(false); +}); +``` + +- [ ] **Step 2: Запустить тест — должен FAIL** + +`cd app && npx vitest run tests/Frontend/DealsView.spec.ts -t "при selected" --reporter=verbose` + +- [ ] **Step 3: Добавить watcher в DealsView.vue** + +Найти `watch([filterStatus, filterProject, receivedFrom, receivedTo, perPage], …)` (~строка 108) и **после** добавить: + +```ts +// Selected-driven drawer visibility (18.05.2026 ux-request): +// 0 selected → drawer по row-click; 1 selected → авто-открыт для этой сделки; +// ≥2 selected → закрыт (показывается bulk-полоса). +watch(selected, (ids) => { + if (ids.length === 1) { + const deal = dealsState.find((d) => d.id === ids[0]); + if (deal) { + selectedDeal.value = deal; + panelOpen.value = true; + } + } else if (ids.length >= 2) { + panelOpen.value = false; + } +}); +``` + +И **скрыть bulk-полосу при selected=1** — изменить отображение DealsBulkBar: + +```vue + +``` + +- [ ] **Step 4: Vitest пройти GREEN** + +`cd app && npx vitest run tests/Frontend/DealsView.spec.ts --reporter=default` +Expected: все passes, в т.ч. 2 новых. + +- [ ] **Step 5: Commit** + +```bash +git add app/resources/js/views/DealsView.vue app/tests/Frontend/DealsView.spec.ts +git commit -m "feat(deals): drawer виден при selected≤1, bulk-полоса только при ≥2" +``` + +--- + +## Task 2: API: project source fields в drawer сделки (пп. 4+6+7) + +**Files:** + +- Modify: `app/app/Http/Controllers/Api/DealController.php` (eager-load + payload) +- Modify: `app/resources/js/api/deals.ts` (ApiDeal +4 поля) +- Modify: `app/resources/js/composables/mockDeals.ts` (MockDeal +4 поля) +- Modify: `app/resources/js/composables/dealsApiMapper.ts` (маппер +4 поля) +- Modify: `app/resources/js/components/deals/DealDetailBody.vue` (UI: убрать Менеджер, +Тип, +Источник) +- Test: `app/tests/Feature/Deals/DealShowEndpointTest.php` или подобный для controller; `app/tests/Frontend/DealDetailBody.spec.ts` если есть, иначе расширить DealDetailDrawer.spec.ts + +- [ ] **Step 1: Pest failing для API payload** + +В существующем тесте `app/tests/Feature/Deals/*.php` для GET /api/deals/{id} добавить assertion: + +```php +it('returns project signal_identifier/sms_keyword/sms_senders in deal payload', function () { + $tenant = Tenant::factory()->create(); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'signal_type' => 'sms', + 'signal_identifier' => 'MTS', + 'sms_senders' => ['MTS', 'BEELINE'], + 'sms_keyword' => 'КРЕДИТ', + ]); + $deal = Deal::factory()->create(['tenant_id' => $tenant->id, 'project_id' => $project->id]); + + actingAsTenant($tenant); + $resp = $this->getJson("/api/deals/{$deal->id}?tenant_id={$tenant->id}"); + + $resp->assertOk()->assertJsonPath('deal.project_signal_identifier', 'MTS'); + $resp->assertJsonPath('deal.project_sms_keyword', 'КРЕДИТ'); + $resp->assertJsonPath('deal.project_sms_senders.0', 'MTS'); +}); +``` + +- [ ] **Step 2: Запустить — FAIL** + +``` +cd app && ./vendor/bin/pest --filter "returns project signal_identifier" +``` + +Expected: FAIL (поля отсутствуют в payload). + +- [ ] **Step 3: Расширить DealController eager-load + transformer** + +В `app/app/Http/Controllers/Api/DealController.php`: + +- Строка 112 (`->with(['project:id,name,signal_type', ...])`) → расширить: + `->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);` +- Строка 215 (`'project_signal_type' => …`) → добавить ниже: + +```php +'project_signal_identifier' => $d->project?->signal_identifier, +'project_sms_keyword' => $d->project?->sms_keyword, +'project_sms_senders' => $d->project?->sms_senders, +``` + +Найти аналогичные места в `show()` методе (если есть) и добавить там же. + +- [ ] **Step 4: Pest passes** + +``` +cd app && ./vendor/bin/pest --filter "returns project signal_identifier" -v +``` + +Expected: PASS. + +- [ ] **Step 5: Расширить TypeScript интерфейсы** + +В `app/resources/js/api/deals.ts` интерфейс `ApiDeal` (строка 153) — добавить: + +```ts +project_signal_identifier: string | null; +project_sms_keyword: string | null; +project_sms_senders: string[] | null; +``` + +В `app/resources/js/composables/mockDeals.ts` интерфейс `MockDeal` (строка 10) — добавить: + +```ts +projectSignalType?: 'site' | 'call' | 'sms' | null; +projectSignalIdentifier?: string | null; +projectSmsKeyword?: string | null; +projectSmsSenders?: string[] | null; +``` + +В `app/resources/js/composables/dealsApiMapper.ts` функция `mapApiDeal` — добавить маппинг новых полей. +**ПРИМЕЧАНИЕ:** прочитать актуальный файл перед правкой, см. поля `signalType: d.project_signal_type as MockDeal['signalType']` — добавить аналогично: + +```ts +projectSignalType: d.project_signal_type as MockDeal['projectSignalType'], +projectSignalIdentifier: d.project_signal_identifier, +projectSmsKeyword: d.project_sms_keyword, +projectSmsSenders: d.project_sms_senders, +``` + +- [ ] **Step 6: Failing Vitest test для UI Drawer** + +Создать `app/tests/Frontend/DealDetailBody.spec.ts` (или расширить, если есть): + +```ts +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import { setActivePinia, createPinia } from 'pinia'; +import DealDetailBody from '../../resources/js/components/deals/DealDetailBody.vue'; +import type { MockDeal } from '../../resources/js/composables/mockDeals'; + +const vuetify = createVuetify(); +setActivePinia(createPinia()); + +function makeDeal(overrides: Partial = {}): MockDeal { + return { + id: 1, name: 'A', phone: '+79991234567', statusSlug: 'new', + project: 'p', manager: { initials: 'AD', name: 'A' }, cost: 0, + receivedMinutesAgo: 1, + projectSignalType: 'site', projectSignalIdentifier: 'krk-finance.ru', + projectSmsKeyword: null, projectSmsSenders: null, + ...overrides, + }; +} + +describe('DealDetailBody — Тип и Источник (18.05.2026)', () => { + it('показывает Тип «Сайт» и Источник = signal_identifier для site', () => { + const w = mount(DealDetailBody, { + props: { deal: makeDeal() }, + global: { plugins: [vuetify, createPinia()] }, + }); + expect(w.text()).toContain('Сайт'); + expect(w.text()).toContain('krk-finance.ru'); + }); + + it('для sms показывает sender + (keyword)', () => { + const w = mount(DealDetailBody, { + props: { deal: makeDeal({ + projectSignalType: 'sms', + projectSignalIdentifier: null, + projectSmsSenders: ['MTS', 'BEELINE'], + projectSmsKeyword: 'КРЕДИТ', + }) }, + global: { plugins: [vuetify, createPinia()] }, + }); + expect(w.text()).toContain('СМС'); + expect(w.text()).toContain('MTS (КРЕДИТ)'); + }); + + it('для sms без keyword показывает только sender', () => { + const w = mount(DealDetailBody, { + props: { deal: makeDeal({ + projectSignalType: 'sms', + projectSignalIdentifier: null, + projectSmsSenders: ['MTS'], + projectSmsKeyword: null, + }) }, + global: { plugins: [vuetify, createPinia()] }, + }); + expect(w.text()).toContain('СМС'); + expect(w.text()).toContain('MTS'); + expect(w.text()).not.toMatch(/\([^)]*\)/); + }); + + it('не отображает «Менеджер» секцию', () => { + const w = mount(DealDetailBody, { + props: { deal: makeDeal() }, + global: { plugins: [vuetify, createPinia()] }, + }); + expect(w.text()).not.toContain('Менеджер'); + }); +}); +``` + +- [ ] **Step 7: Run — FAIL** + +`cd app && npx vitest run tests/Frontend/DealDetailBody.spec.ts --reporter=verbose` + +Expected: 4 fails. + +- [ ] **Step 8: Реализация DealDetailBody.vue** + +В `app/resources/js/components/deals/DealDetailBody.vue`: + +1. Добавить helpers в `