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:
Дмитрий
2026-05-18 15:12:07 +03:00
parent 15112093c8
commit ad021bb650
2 changed files with 888 additions and 0 deletions
+3
View File
@@ -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`. Если такого нет — использовать существующий паттерн авторизации в тестах.