# 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`. Если такого нет — использовать существующий паттерн авторизации в тестах.
