docs(plans): план эпика «Сделки drawer + редактирование источника проекта»
5 атомарных задач, согласованы вопросами AskUserQuestion 18.05.2026:
- Task 1: drawer visibility 0/1 vs ≥2 (пп.1+2)
- Task 2: «Менеджер» → «Тип» + «Источник» read-only в drawer (пп.4/6/7)
- Task 3: inline status picker (п.3)
- Task 4: подписи «Источник» в NewProjectDialog (п.8)
- Task 5: редактирование source в ProjectDetailsDrawer (п.9, backend+UI)
п.5 (B-префикс) уже закрыт в 1511209.
cspell: +табах.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1436,3 +1436,6 @@ evals
|
||||
волатильны
|
||||
незакоммиченное
|
||||
бандл
|
||||
|
||||
# План «Сделки drawer + редактирование источника» (2026-05-18)
|
||||
табах
|
||||
|
||||
@@ -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
|
||||
<DealsBulkBar
|
||||
v-if="selected.length >= 2"
|
||||
v-model:status-menu-open="statusMenuOpen"
|
||||
:selected-count="selected.length"
|
||||
:lead-statuses="leadStatuses"
|
||||
@apply-status="applyBulkStatus"
|
||||
@clear-selected="selected = []"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **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> = {}): 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 в `<script setup>`:
|
||||
|
||||
```ts
|
||||
const TYPE_LABELS: Record<string, string> = { site: 'Сайт', call: 'Звонок', sms: 'СМС' };
|
||||
const projectTypeLabel = computed((): string =>
|
||||
props.deal?.projectSignalType ? (TYPE_LABELS[props.deal.projectSignalType] ?? '—') : '—',
|
||||
);
|
||||
const projectSourceLabel = computed((): string => {
|
||||
if (!props.deal) return '—';
|
||||
const t = props.deal.projectSignalType;
|
||||
if (t === 'site' || t === 'call') return props.deal.projectSignalIdentifier ?? '—';
|
||||
if (t === 'sms') {
|
||||
const sender = props.deal.projectSmsSenders?.[0] ?? '';
|
||||
const kw = props.deal.projectSmsKeyword;
|
||||
if (sender && kw) return `${sender} (${kw})`;
|
||||
return sender || '—';
|
||||
}
|
||||
return '—';
|
||||
});
|
||||
```
|
||||
|
||||
1. В `<template>` найти блок `<div class="param">` для «Менеджер» (около строки 171-180) — **удалить целиком**.
|
||||
2. Между «Стоимость лида» и (где был Менеджер) добавить:
|
||||
|
||||
```vue
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Тип</dt>
|
||||
<dd class="text-body-2">{{ projectTypeLabel }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Источник</dt>
|
||||
<dd class="text-body-2">{{ projectSourceLabel }}</dd>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Vitest GREEN**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealDetailBody.spec.ts --reporter=default`
|
||||
Expected: 4 passes.
|
||||
|
||||
- [ ] **Step 10: Full Vitest перед коммитом**
|
||||
|
||||
`cd app && npx vitest run --reporter=default --maxWorkers=2`
|
||||
|
||||
Expected: 0 failed.
|
||||
|
||||
- [ ] **Step 11: Pest full перед коммитом**
|
||||
|
||||
`cd app && ./vendor/bin/pest --parallel 2>&1 | tail -10`
|
||||
|
||||
Expected: 0 failed (новый Deal-show-payload тест passes).
|
||||
|
||||
- [ ] **Step 12: Build**
|
||||
|
||||
`cd app && npm run build 2>&1 | tail -5`
|
||||
|
||||
Expected: `built in …s`.
|
||||
|
||||
- [ ] **Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/DealController.php \
|
||||
app/resources/js/api/deals.ts \
|
||||
app/resources/js/composables/mockDeals.ts \
|
||||
app/resources/js/composables/dealsApiMapper.ts \
|
||||
app/resources/js/components/deals/DealDetailBody.vue \
|
||||
app/tests/Frontend/DealDetailBody.spec.ts \
|
||||
app/tests/Feature/Deals/*.php
|
||||
git commit -m "feat(deals/drawer): убрать «Менеджер», добавить «Тип» + «Источник» read-only"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Inline status picker в drawer (п. 3)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/deals/DealDetailHero.vue`
|
||||
- Test: `app/tests/Frontend/DealDetailHero.spec.ts` (создать или расширить)
|
||||
|
||||
**Логика:** клик по статус-чипу → `v-menu` с `v-list` всех статусов → выбор отправляет `PATCH /api/deals/{id} { status: <slug> }`, optimistic UI.
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import DealDetailHero from '../../resources/js/components/deals/DealDetailHero.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const statuses = [
|
||||
{ slug: 'new', nameRu: 'Новая', colorHex: '#0F6E56' },
|
||||
{ slug: 'in_progress', nameRu: 'В работе', colorHex: '#0066CC' },
|
||||
{ slug: 'won', nameRu: 'Куплено', colorHex: '#00A36C' },
|
||||
];
|
||||
|
||||
describe('DealDetailHero — inline status picker', () => {
|
||||
it('клик по статус-чипу открывает меню статусов', async () => {
|
||||
const w = mount(DealDetailHero, {
|
||||
props: {
|
||||
deal: { id: 1, name: 'A', phone: '+1', statusSlug: 'new', project: 'p',
|
||||
manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 1 },
|
||||
status: statuses[0],
|
||||
allStatuses: statuses,
|
||||
},
|
||||
global: { plugins: [vuetify], stubs: { teleport: true } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await w.find('[data-testid="status-chip-trigger"]').trigger('click');
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(document.body.textContent).toContain('В работе');
|
||||
expect(document.body.textContent).toContain('Куплено');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('выбор статуса эмитит change-status с новым slug', async () => {
|
||||
const w = mount(DealDetailHero, {
|
||||
props: {
|
||||
deal: { id: 1, name: 'A', phone: '+1', statusSlug: 'new', project: 'p',
|
||||
manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 1 },
|
||||
status: statuses[0],
|
||||
allStatuses: statuses,
|
||||
},
|
||||
global: { plugins: [vuetify], stubs: { teleport: true } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await w.find('[data-testid="status-chip-trigger"]').trigger('click');
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const items = [...document.body.querySelectorAll('[data-testid^="status-option-"]')];
|
||||
const won = items.find(el => el.textContent?.includes('Куплено')) as HTMLElement | undefined;
|
||||
won?.click();
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(w.emitted('change-status')?.[0]?.[0]).toBe('won');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealDetailHero.spec.ts -v`
|
||||
|
||||
- [ ] **Step 3: Расширить DealDetailHero.vue**
|
||||
|
||||
В `<script setup>` props:
|
||||
|
||||
```ts
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
defineProps<{
|
||||
deal: MockDeal;
|
||||
status: LeadStatus | null;
|
||||
allStatuses: LeadStatus[];
|
||||
}>();
|
||||
defineEmits<{
|
||||
close: [];
|
||||
'change-status': [slug: string];
|
||||
}>();
|
||||
```
|
||||
|
||||
В `<template>` блок `<div v-if="status" class="status-row mt-3">` (строки 43-48) — заменить на:
|
||||
|
||||
```vue
|
||||
<div v-if="status" class="status-row mt-3">
|
||||
<v-menu>
|
||||
<template #activator="{ props: a }">
|
||||
<v-chip v-bind="a" data-testid="status-chip-trigger" size="small" variant="tonal"
|
||||
:style="{ color: status.colorHex, borderColor: status.colorHex, cursor: 'pointer' }">
|
||||
<span class="status-dot" :style="{ background: status.colorHex }" />
|
||||
{{ status.nameRu }}
|
||||
<v-icon size="14" class="ml-1">mdi-menu-down</v-icon>
|
||||
</v-chip>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="s in allStatuses" :key="s.slug"
|
||||
:data-testid="`status-option-${s.slug}`"
|
||||
@click="$emit('change-status', s.slug)">
|
||||
<template #prepend>
|
||||
<span class="status-dot" :style="{ background: s.colorHex }" />
|
||||
</template>
|
||||
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Расширить parent DealDetailBody.vue**
|
||||
|
||||
Передавать allStatuses и обрабатывать change-status. В `DealDetailBody.vue`:
|
||||
|
||||
```vue
|
||||
<DealDetailHero :deal="deal" :status="status" :all-statuses="leadStatusesStore.statuses"
|
||||
@close="emit('close')" @change-status="onStatusChange" />
|
||||
```
|
||||
|
||||
Добавить handler:
|
||||
|
||||
```ts
|
||||
async function onStatusChange(slug: string): Promise<void> {
|
||||
if (!props.deal || !props.tenantId) return;
|
||||
const prev = props.deal.statusSlug;
|
||||
props.deal.statusSlug = slug as MockDeal['statusSlug'];
|
||||
try {
|
||||
await dealsApi.updateDeal(props.deal.id, { tenant_id: props.tenantId, status: slug });
|
||||
} catch {
|
||||
props.deal.statusSlug = prev;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Vitest GREEN**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealDetailHero.spec.ts -v`
|
||||
|
||||
- [ ] **Step 6: Full Vitest**
|
||||
|
||||
`cd app && npx vitest run --reporter=default --maxWorkers=2 2>&1 | tail -10`
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/deals/DealDetailHero.vue \
|
||||
app/resources/js/components/deals/DealDetailBody.vue \
|
||||
app/tests/Frontend/DealDetailHero.spec.ts
|
||||
git commit -m "feat(deals/drawer): inline status picker в карточке сделки"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Подпись «Источник» в NewProjectDialog (п. 8)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/projects/NewProjectDialog.vue`
|
||||
- Test: `app/tests/Frontend/NewProjectDialog.spec.ts` (создать или расширить если есть)
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
describe('NewProjectDialog — подпись «Источник» (18.05.2026)', () => {
|
||||
it('на табе Сайт перед полем «Домен» есть подпись «Источник»', async () => {
|
||||
const w = mount(NewProjectDialog, {
|
||||
props: { modelValue: true, mode: 'create' },
|
||||
global: { plugins: [vuetify], stubs: { teleport: true } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(document.body.textContent).toContain('Источник');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/NewProjectDialog.spec.ts -v`
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
В `app/resources/js/views/projects/NewProjectDialog.vue` — внутри `<v-tabs-window v-model="form.signal_type" class="mt-4">` (строка 19) — перед каждым `<v-tabs-window-item>` контентом добавить заголовок секции:
|
||||
|
||||
Для **site** (после `<v-tabs-window-item value="site">`):
|
||||
|
||||
```vue
|
||||
<div class="text-caption text-medium-emphasis mb-1">Источник — домен сайта-«донора», с которого приходят лиды</div>
|
||||
```
|
||||
|
||||
Для **call**:
|
||||
|
||||
```vue
|
||||
<div class="text-caption text-medium-emphasis mb-1">Источник — телефонный номер «донора», на который звонят клиенты</div>
|
||||
```
|
||||
|
||||
Для **sms**:
|
||||
|
||||
```vue
|
||||
<div class="text-caption text-medium-emphasis mb-1">Источник — отправитель SMS и (опционально) ключевое слово в тексте</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vitest GREEN**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/NewProjectDialog.spec.ts -v`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/projects/NewProjectDialog.vue \
|
||||
app/tests/Frontend/NewProjectDialog.spec.ts
|
||||
git commit -m "feat(projects/new-dialog): подпись «Источник» над полями на 3 табах"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Редактирование источника в ProjectDetailsDrawer (п. 9)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Requests/UpdateProjectRequest.php` (+signal_identifier rules)
|
||||
- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (update — пропускать signal_identifier)
|
||||
- Modify: `app/resources/js/components/projects/ProjectDetailsDrawer.vue` (UI поля)
|
||||
- Test: `app/tests/Feature/Projects/UpdateProjectTest.php` (signal_identifier обновляется)
|
||||
|
||||
**ВАЖНО (риски):**
|
||||
|
||||
- signal_type **не редактируется** — менять signal_type у активного проекта = смена природы; не предусмотрено.
|
||||
- При смене signal_identifier у существующего проекта **прошлые сделки** уже привязаны к проекту, source-label в их карточке изменится автоматически (это поведение и хотел заказчик: «изменится у всех сделок этого проекта»).
|
||||
- Соблюсти validation: site = regex домена, call = regex 7\d{10}, sms = signal_identifier = sms_senders[0] (или просто пропускать через sms_senders/sms_keyword, как сейчас в UpdateProjectRequest).
|
||||
|
||||
- [ ] **Step 1: Pest failing**
|
||||
|
||||
Создать или расширить `app/tests/Feature/Projects/UpdateProjectTest.php`:
|
||||
|
||||
```php
|
||||
it('updates signal_identifier for site project', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'old.ru',
|
||||
]);
|
||||
actingAsTenant($tenant);
|
||||
|
||||
$resp = $this->patchJson("/api/projects/{$project->id}", [
|
||||
'signal_identifier' => 'new-source.ru',
|
||||
]);
|
||||
|
||||
$resp->assertOk();
|
||||
expect($project->fresh()->signal_identifier)->toBe('new-source.ru');
|
||||
});
|
||||
|
||||
it('updates signal_identifier for call project (phone regex)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79991111111',
|
||||
]);
|
||||
actingAsTenant($tenant);
|
||||
|
||||
$resp = $this->patchJson("/api/projects/{$project->id}", [
|
||||
'signal_identifier' => '79992222222',
|
||||
]);
|
||||
$resp->assertOk();
|
||||
expect($project->fresh()->signal_identifier)->toBe('79992222222');
|
||||
});
|
||||
|
||||
it('rejects invalid signal_identifier (site regex)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'ok.ru',
|
||||
]);
|
||||
actingAsTenant($tenant);
|
||||
|
||||
$this->patchJson("/api/projects/{$project->id}", ['signal_identifier' => 'not-a-domain'])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['signal_identifier']);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --filter "updates signal_identifier\|rejects invalid"
|
||||
```
|
||||
|
||||
Expected: все FAIL (правило не разрешает signal_identifier).
|
||||
|
||||
- [ ] **Step 3: Расширить UpdateProjectRequest**
|
||||
|
||||
В `app/app/Http/Requests/UpdateProjectRequest.php` в массиве правил (около строки 27) — добавить condition-based валидацию:
|
||||
|
||||
```php
|
||||
$rules = [
|
||||
// ... existing rules name/daily_limit/regions/delivery_days_mask/sms_*
|
||||
];
|
||||
|
||||
// Условно на тип проекта — signal_identifier валидируется по signal_type
|
||||
$project = $this->route('id') ? \App\Models\Project::find($this->route('id')) : null;
|
||||
if ($project) {
|
||||
if ($project->signal_type === 'site') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i'];
|
||||
} elseif ($project->signal_type === 'call') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^7\d{10}$/'];
|
||||
}
|
||||
// sms: signal_identifier обновляется автоматически из sms_senders[0] в ProjectController (или через UpdateProjectAction)
|
||||
}
|
||||
|
||||
return $rules;
|
||||
```
|
||||
|
||||
(Адаптировать под существующую структуру файла — прочитать его сначала.)
|
||||
|
||||
- [ ] **Step 4: Расширить ProjectController::update**
|
||||
|
||||
В `app/app/Http/Controllers/Api/ProjectController.php` метод `update()` (около строки 96) — пропустить signal_identifier через assign:
|
||||
|
||||
```php
|
||||
$validated = $request->validated();
|
||||
$project = Project::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
// signal_type не меняем — explicitly NOT в $fillable update
|
||||
$updates = collect($validated)->only([
|
||||
'name', 'daily_limit_target', 'regions', 'delivery_days_mask',
|
||||
'sms_senders', 'sms_keyword', 'signal_identifier',
|
||||
])->toArray();
|
||||
|
||||
$project->update($updates);
|
||||
return response()->json(['project' => $project->fresh()]);
|
||||
```
|
||||
|
||||
(Адаптировать под существующую логику.)
|
||||
|
||||
- [ ] **Step 5: Pest GREEN**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --filter "updates signal_identifier\|rejects invalid"
|
||||
```
|
||||
|
||||
Expected: 3 pass.
|
||||
|
||||
- [ ] **Step 6: Расширить ProjectDetailsDrawer.vue**
|
||||
|
||||
В `app/resources/js/components/projects/ProjectDetailsDrawer.vue` интерфейс `FormState` (около строки 12) — добавить:
|
||||
|
||||
```ts
|
||||
signal_identifier: string;
|
||||
```
|
||||
|
||||
И в `reseedFromProject` (около строки 32) — добавить:
|
||||
|
||||
```ts
|
||||
form.signal_identifier = p.signal_identifier ?? '';
|
||||
```
|
||||
|
||||
В `<template>` после `<label class="pdd-field">` блока «Название» (строки 124-128) — добавить блок «Источник», условно по signal_type:
|
||||
|
||||
```vue
|
||||
<label v-if="project?.signal_type === 'site'" class="pdd-field">
|
||||
<span class="pdd-label">Источник (домен сайта)</span>
|
||||
<input v-model="form.signal_identifier" data-testid="pdd-signal-identifier" class="pdd-input"
|
||||
placeholder="okna-konkurent.ru" />
|
||||
<div v-if="errors.signal_identifier" class="pdd-error">{{ errors.signal_identifier[0] }}</div>
|
||||
</label>
|
||||
<label v-else-if="project?.signal_type === 'call'" class="pdd-field">
|
||||
<span class="pdd-label">Источник (телефонный номер)</span>
|
||||
<input v-model="form.signal_identifier" data-testid="pdd-signal-identifier" class="pdd-input"
|
||||
placeholder="79161234567" />
|
||||
<div v-if="errors.signal_identifier" class="pdd-error">{{ errors.signal_identifier[0] }}</div>
|
||||
</label>
|
||||
<!-- sms: signal_identifier подтягивается из sms_senders, отдельное поле не нужно -->
|
||||
<label v-else-if="project?.signal_type === 'sms'" class="pdd-field">
|
||||
<span class="pdd-label">Отправители SMS (до 11 символов каждый)</span>
|
||||
<v-combobox v-model="form.sms_senders" multiple chips clearable
|
||||
data-testid="pdd-sms-senders" hide-details />
|
||||
</label>
|
||||
<label v-if="project?.signal_type === 'sms'" class="pdd-field">
|
||||
<span class="pdd-label">Ключевое слово (опционально)</span>
|
||||
<input v-model="form.sms_keyword" data-testid="pdd-sms-keyword" class="pdd-input" />
|
||||
</label>
|
||||
```
|
||||
|
||||
В `onSave()` — добавить signal_identifier в payload:
|
||||
|
||||
```ts
|
||||
if (props.project.signal_type === 'site' || props.project.signal_type === 'call') {
|
||||
payload.signal_identifier = form.signal_identifier;
|
||||
}
|
||||
if (props.project.signal_type === 'sms') {
|
||||
payload.sms_senders = form.sms_senders;
|
||||
payload.sms_keyword = form.sms_keyword;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Vitest для ProjectDetailsDrawer**
|
||||
|
||||
Создать `app/tests/Frontend/ProjectDetailsDrawer.spec.ts` (если нет) с тестом:
|
||||
|
||||
```ts
|
||||
it('показывает поле редактирования signal_identifier для site-проекта', () => {
|
||||
const w = mount(ProjectDetailsDrawer, {
|
||||
props: { project: { id: 1, tenant_id: 1, name: 'P', signal_type: 'site',
|
||||
signal_identifier: 'old.ru', daily_limit_target: 50,
|
||||
regions: [], delivery_days_mask: 127, is_active: true } as never },
|
||||
global: { plugins: [vuetify, createPinia()] },
|
||||
});
|
||||
const input = w.find('[data-testid="pdd-signal-identifier"]');
|
||||
expect(input.exists()).toBe(true);
|
||||
expect((input.element as HTMLInputElement).value).toBe('old.ru');
|
||||
});
|
||||
```
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/ProjectDetailsDrawer.spec.ts -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Full Pest + Vitest + Build**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --parallel 2>&1 | tail -10
|
||||
cd app && npx vitest run --reporter=default --maxWorkers=2 2>&1 | tail -10
|
||||
cd app && npm run build 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: всё GREEN.
|
||||
|
||||
- [ ] **Step 9: Manual smoke на /projects**
|
||||
|
||||
Открыть `http://127.0.0.1:8000/projects` → клик на любой site-проект → drawer справа → поле «Источник» видно с текущим доменом → меняем → «Сохранить» → перезагрузка карточки → новое значение применилось → перейти на `/deals` → drawer сделки этого проекта → «Источник» = новое значение.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Requests/UpdateProjectRequest.php \
|
||||
app/app/Http/Controllers/Api/ProjectController.php \
|
||||
app/resources/js/components/projects/ProjectDetailsDrawer.vue \
|
||||
app/tests/Frontend/ProjectDetailsDrawer.spec.ts \
|
||||
app/tests/Feature/Projects/UpdateProjectTest.php
|
||||
git commit -m "feat(projects/drawer): редактирование источника (site/call/sms) в карточке проекта"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Финал: атомарный push
|
||||
|
||||
После Task 5 — push всех 5 коммитов одним:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git log HEAD..origin/main --oneline # должно быть пусто
|
||||
git push origin feat/parallel-sessions-coordination:main
|
||||
```
|
||||
|
||||
Эталон обновить: §1 HEAD, §6 «Текущие нити» добавить запись «18.05 — деали-drawer + edit-источник проекта».
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
- п.1+2 ✅ Task 1
|
||||
- п.3 ✅ Task 3
|
||||
- п.4 ✅ Task 2 (Step 8 удаляет блок «Менеджер»)
|
||||
- п.5 ✅ уже сделано в `36ea9cd`
|
||||
- п.6 ✅ Task 2 (computed projectSourceLabel)
|
||||
- п.7 ✅ Task 2 (computed projectTypeLabel)
|
||||
- п.8 ✅ Task 4
|
||||
- п.9 ✅ Task 5
|
||||
|
||||
**Placeholder scan:** в плане НЕТ «TBD»/«TODO», все code-блоки полные.
|
||||
|
||||
**Type consistency:** `projectSignalType`/`projectSignalIdentifier`/`projectSmsKeyword`/`projectSmsSenders` consistent через Task 2 (api/mockDeals/mapper/UI). `allStatuses` consistent в Task 3 (Hero ← Body).
|
||||
|
||||
**Риски:**
|
||||
|
||||
- Task 5 затрагивает критическое поле `signal_identifier`; нет ограничения «нельзя менять у проекта с уже накопленными сделками». При желании заказчика добавить — отдельная задача (warn-dialog типа Task 5.5). Не делаем в MVP — заказчик прямо сказал «изменится у всех сделок этого проекта» (он понимает scope).
|
||||
- Pest тесты предполагают наличие `actingAsTenant()` helper'а в `app/tests/Pest.php`. Если такого нет — использовать существующий паттерн авторизации в тестах.
|
||||
Reference in New Issue
Block a user