# Project Source-Edit Lock UX — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.

**Goal:** Показать клиенту, почему и до какой даты нельзя менять источник проекта (вместо тихого 422), + баннер «когда пойдут лиды» в форме нового проекта. Логику `SupplierSnapshotGuard` НЕ меняем.

**Architecture:** Бэкенд отдаёт read-only поля `source_locked` / `source_unlock_at` / `source_unlock_projected` (считает новый ПУРЕ-метод `SupplierSnapshotGuard::lockState`, без новых DB-запросов — через `withCount('supplierProjects')`). Фронт блокирует поля источника + рисует подсказку; форма нового проекта показывает баннер-объявление.

**Tech Stack:** Laravel 13 / Pest 4 (бэкенд), Vue 3 + Vuetify 3 / Vitest (фронт).

Спека: `docs/superpowers/specs/2026-06-22-project-source-edit-lock-ux-design.md`.

---

## Task 1: `SupplierSnapshotGuard::lockState` (pure, без DB)

**Files:**

- Modify: `app/app/Services/Project/SupplierSnapshotGuard.php`
- Test: `app/tests/Unit/Project/SupplierSnapshotGuardLockStateTest.php`

- [ ] **Step 1: Failing test**

```php
<?php
declare(strict_types=1);
use App\Services\Project\SupplierSnapshotGuard;
use Carbon\CarbonImmutable;

function ls(): SupplierSnapshotGuard { return new SupplierSnapshotGuard(); }

it('no supplier links → not locked', function () {
    $r = ls()->lockState(hasLinks: false, isActive: true, pausedAt: null);
    expect($r['locked'])->toBeFalse();
    expect($r['unlock_at'])->toBeNull();
    expect($r['projected'])->toBeFalse();
});

it('active + links → locked, unlock_at projected from now', function () {
    $now = CarbonImmutable::parse('2026-06-22 16:00:00', 'Europe/Moscow'); // до 21:00
    $r = ls()->lockState(hasLinks: true, isActive: true, pausedAt: null, now: $now);
    expect($r['locked'])->toBeTrue();
    expect($r['projected'])->toBeTrue();
    // pause-now(16:00) → next21=22.06 21:00 → +24h = 23.06 21:00
    expect($r['unlock_at']->toIso8601String())->toBe('2026-06-23T21:00:00+03:00');
});

it('paused within grace → locked, firm unlock_at from paused_at', function () {
    $paused = CarbonImmutable::parse('2026-06-22 16:00:00', 'Europe/Moscow');
    $now = CarbonImmutable::parse('2026-06-22 18:00:00', 'Europe/Moscow');
    $r = ls()->lockState(hasLinks: true, isActive: false, pausedAt: $paused, now: $now);
    expect($r['locked'])->toBeTrue();
    expect($r['projected'])->toBeFalse();
    expect($r['unlock_at']->toIso8601String())->toBe('2026-06-23T21:00:00+03:00');
});

it('paused after grace → not locked', function () {
    $paused = CarbonImmutable::parse('2026-06-20 16:00:00', 'Europe/Moscow');
    $now = CarbonImmutable::parse('2026-06-22 18:00:00', 'Europe/Moscow'); // > grace(21.06 21:00)
    $r = ls()->lockState(hasLinks: true, isActive: false, pausedAt: $paused, now: $now);
    expect($r['locked'])->toBeFalse();
    expect($r['unlock_at'])->toBeNull();
});

it('paused but paused_at null → not locked', function () {
    $r = ls()->lockState(hasLinks: true, isActive: false, pausedAt: null);
    expect($r['locked'])->toBeFalse();
});
```

- [ ] **Step 2: Run — fails (method missing)**
Run: `cd app && ./vendor/bin/pest tests/Unit/Project/SupplierSnapshotGuardLockStateTest.php`
Expected: FAIL (Call to undefined method lockState).

- [ ] **Step 3: Implement** — add method to `SupplierSnapshotGuard` (НЕ трогая `isProtected`/`computeGraceUntil`/`appliesFrom`):

```php
/**
 * Pure-вариант isProtected для presentation-слоя: считает состояние блокировки
 * источника БЕЗ DB-запроса (hasLinks передаётся снаружи — eager-loaded count).
 *
 * @return array{locked: bool, unlock_at: ?\Carbon\CarbonImmutable, projected: bool}
 *   projected=true → дата это прогноз «если поставить паузу сейчас» (проект активен).
 */
public function lockState(
    bool $hasLinks,
    bool $isActive,
    ?CarbonInterface $pausedAt,
    ?CarbonImmutable $now = null,
): array {
    $now ??= CarbonImmutable::now('Europe/Moscow');

    if (! $hasLinks) {
        return ['locked' => false, 'unlock_at' => null, 'projected' => false];
    }
    if ($isActive) {
        // Паузы ещё нет — прогноз от «если поставить паузу сейчас».
        return ['locked' => true, 'unlock_at' => $this->computeGraceUntil($now), 'projected' => true];
    }
    if ($pausedAt === null) {
        return ['locked' => false, 'unlock_at' => null, 'projected' => false];
    }
    $graceUntil = $this->computeGraceUntil($pausedAt);
    if ($now->lt($graceUntil)) {
        return ['locked' => true, 'unlock_at' => $graceUntil, 'projected' => false];
    }

    return ['locked' => false, 'unlock_at' => null, 'projected' => false];
}
```

- [ ] **Step 4: Run — passes**
Run: `cd app && ./vendor/bin/pest tests/Unit/Project/SupplierSnapshotGuardLockStateTest.php`
Expected: PASS (5 tests).

- [ ] **Step 5: Commit** `git add app/app/Services/Project/SupplierSnapshotGuard.php app/tests/Unit/Project/SupplierSnapshotGuardLockStateTest.php && git commit`

---

## Task 2: `ProjectResource` отдаёт 3 поля

**Files:**

- Modify: `app/app/Http/Resources/ProjectResource.php`
- Test: `app/tests/Feature/Http/Resources/ProjectResourceSourceLockTest.php`

- [ ] **Step 1: Failing test** — проект с supplier-связью; hasLinks берём из `supplier_projects_count` (loaded) либо из реальной связи. Тест создаёт pivot-строку.

```php
<?php
declare(strict_types=1);
use App\Http\Resources\ProjectResource;
use App\Models\{Project, Tenant, SupplierProject};
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

function linkedProject(bool $active, ?string $pausedAt = null): Project {
    $tenant = Tenant::factory()->create();
    $p = Project::factory()->for($tenant)->create(['is_active' => $active, 'paused_at' => $pausedAt]);
    $sp = SupplierProject::factory()->create();
    DB::table('project_supplier_links')->insert([
        'project_id' => $p->id, 'supplier_project_id' => $sp->id, 'platform' => $sp->platform, 'subject_code' => null,
    ]);
    return $p->loadCount('supplierProjects');
}

it('active linked project → source_locked true + projected', function () {
    $res = (new ProjectResource(linkedProject(true)))->toArray(request());
    expect($res['source_locked'])->toBeTrue();
    expect($res['source_unlock_projected'])->toBeTrue();
    expect($res['source_unlock_at'])->not->toBeNull();
});

it('project with no supplier links → source_locked false', function () {
    $tenant = Tenant::factory()->create();
    $p = Project::factory()->for($tenant)->create(['is_active' => true])->loadCount('supplierProjects');
    $res = (new ProjectResource($p))->toArray(request());
    expect($res['source_locked'])->toBeFalse();
    expect($res['source_unlock_at'])->toBeNull();
});
```

- [ ] **Step 2: Run — fails** (`cd app && ./vendor/bin/pest tests/Feature/Http/Resources/ProjectResourceSourceLockTest.php`) → key missing.

- [ ] **Step 3: Implement** — в `ProjectResource::toArray`, перед `return`, и добавить ключи:

```php
$hasLinks = $this->supplier_projects_count !== null
    ? (int) $this->supplier_projects_count > 0
    : $this->supplierProjects()->exists();
$lock = (new \App\Services\Project\SupplierSnapshotGuard())->lockState(
    hasLinks: $hasLinks,
    isActive: (bool) $this->is_active,
    pausedAt: $this->paused_at,
);
```

и в массив:

```php
'source_locked' => $lock['locked'],
'source_unlock_at' => $lock['unlock_at']?->toIso8601String(),
'source_unlock_projected' => $lock['projected'],
```

- [ ] **Step 4: Run — passes.**
- [ ] **Step 5: Commit.**

---

## Task 3: Индекс/показ — `withCount('supplierProjects')` (анти-N+1)

**Files:** Modify `app/app/Http/Controllers/Api/ProjectController.php`

- [ ] **Step 1:** В методе index добавить `->withCount('supplierProjects')` к запросу (строка ~42, рядом с `->with([...])`). В bulk-ветке (`whereIn('id',...)->get()`) — тоже `->withCount('supplierProjects')`. В show (строка ~238) — `Project::withCount('supplierProjects')->with([...])`. В update (строка ~205, `$updated`) — `$updated->loadCount('supplierProjects')` перед ресурсом; в store/created (`->fresh()`) — `->loadCount('supplierProjects')`.
- [ ] **Step 2: Run** существующий `ProjectResourceAppliesFromTest` + новый Task 2 тест — зелёные.
- [ ] **Step 3:** Быстрый smoke: `cd app && ./vendor/bin/pest tests/Feature/Http --filter=Project` — без регрессий.
- [ ] **Step 4: Commit.**

---

## Task 4: Фронт — тип `Project` + util даты

**Files:**

- Modify: `app/resources/js/stores/projectsStore.ts` (интерфейс Project)
- Create: `app/resources/js/utils/leadDate.ts`
- Test: `app/tests/Frontend/leadDate.spec.ts`

- [ ] **Step 1: Failing test**

```ts
import { describe, it, expect } from 'vitest';
import { formatLeadDate } from '../../resources/js/utils/leadDate';

describe('formatLeadDate', () => {
  it('formats ISO to "D MMMM" in Russian', () => {
    expect(formatLeadDate('2026-06-23T21:00:00+03:00')).toBe('23 июня');
  });
  it('returns empty string for null', () => {
    expect(formatLeadDate(null)).toBe('');
  });
});
```

- [ ] **Step 2: Run — fails** (`npm run test:vue -- leadDate`).

- [ ] **Step 3: Implement** `app/resources/js/utils/leadDate.ts`:

```ts
export function formatLeadDate(iso: string | null | undefined): string {
    if (!iso) return '';
    return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(iso));
}
```

И в `projectsStore.ts` в `interface Project` добавить:

```ts
    source_locked?: boolean;
    source_unlock_at?: string | null;
    source_unlock_projected?: boolean;
```

- [ ] **Step 4: Run — passes.**
- [ ] **Step 5: Commit.**

---

## Task 5: `ProjectDetailsDrawer.vue` — блокировка полей источника + подсказка

**Files:**

- Modify: `app/resources/js/components/projects/ProjectDetailsDrawer.vue`
- Test: `app/tests/Frontend/ProjectDetailsDrawer.spec.ts` (добавить кейсы)

- [ ] **Step 1: Failing test** (добавить в describe):

```ts
it('source field locked + hint when source_locked (active → projected date)', () => {
    const locked: Project = { ...sampleProject, signal_type: 'call', source_locked: true,
        source_unlock_at: '2026-06-23T21:00:00+03:00', source_unlock_projected: true };
    const wrapper = mount(ProjectDetailsDrawer, { props: { project: locked } });
    const src = wrapper.get('input[data-testid="pdd-signal-identifier"]');
    expect((src.element as HTMLInputElement).disabled).toBe(true);
    const hint = wrapper.get('[data-testid="pdd-source-lock-hint"]');
    expect(hint.text()).toContain('поставьте проект на паузу');
    expect(hint.text()).toContain('23 июня');
});

it('source field editable + no hint when not locked', () => {
    const wrapper = mount(ProjectDetailsDrawer, { props: { project: { ...sampleProject, source_locked: false } } });
    expect((wrapper.get('input[data-testid="pdd-signal-identifier"]').element as HTMLInputElement).disabled).toBe(false);
    expect(wrapper.find('[data-testid="pdd-source-lock-hint"]').exists()).toBe(false);
});

it('paused-in-grace shows firm date (no "поставьте на паузу")', () => {
    const locked: Project = { ...sampleProject, is_active: false, source_locked: true,
        source_unlock_at: '2026-06-23T21:00:00+03:00', source_unlock_projected: false };
    const wrapper = mount(ProjectDetailsDrawer, { props: { project: locked } });
    const hint = wrapper.get('[data-testid="pdd-source-lock-hint"]');
    expect(hint.text()).toContain('Изменить источник можно будет 23 июня после 21:00');
});
```

- [ ] **Step 2: Run — fails** (`npm run test:vue -- ProjectDetailsDrawer`).

- [ ] **Step 3: Implement**
  - `<script setup>`: импорт `import { formatLeadDate } from '../../utils/leadDate';` + computed:

```ts
const sourceLocked = computed(() => props.project?.source_locked === true);
const sourceLockHint = computed(() => {
    const d = formatLeadDate(props.project?.source_unlock_at);
    if (props.project?.source_unlock_projected) {
        return `Чтобы изменить источник, поставьте проект на паузу. Лидерра уже собирает по нему лиды.`
            + (d ? ` Поставите паузу сейчас — изменить сможете ${d} после 21:00.` : '');
    }
    return d
        ? `Изменить источник можно будет ${d} после 21:00. Лидерра ещё получает лиды по старому источнику.`
        : `Источник пока изменить нельзя.`;
});
```

- В шаблоне: к трём полям источника (`pdd-signal-identifier` ×2 для site/call, `pdd-sms-senders` combobox, `pdd-sms-keyword` input) добавить `:disabled="sourceLocked"` (для combobox — `:disabled`, для input — `:disabled`). После блока источника (внутри каждой signal-ветки или один общий блок под ними) добавить:

```html
<div v-if="sourceLocked" class="pdd-lock-hint" data-testid="pdd-source-lock-hint">
    🔒 {{ sourceLockHint }}
</div>
```

- Стиль (в `<style scoped>`):

```css
.pdd-lock-hint {
    margin-top: 6px; font-size: 12.5px; line-height: 1.45; color: #23433a;
    background: #f0f6f3; border: 1px solid #cfe4db; border-left: 3px solid #0f6e56;
    border-radius: 6px; padding: 9px 11px;
}
```

- Замок к лейблам полей источника (опц.): добавить « 🔒» в `<span class="pdd-label">` источника при `sourceLocked` — не обязательно для теста, но по макету. Держать просто.

- [ ] **Step 4: Run — passes** (+ существующие 20+ кейсов не сломаны: `npm run test:vue -- ProjectDetailsDrawer`).
- [ ] **Step 5: Commit.**

---

## Task 6: `NewProjectDialog.vue` — баннер «когда пойдут лиды»

**Files:**

- Modify: `app/resources/js/views/projects/NewProjectDialog.vue`
- Test: `app/tests/Frontend/NewProjectDialog.spec.ts` (создать или дополнить)

Дата старта лидов = ближайший слепок по правилу 18:00 (до 18:00 МСК → завтра, после → послезавтра). Считаем на клиенте от текущего времени МСК.

- [ ] **Step 1:** Добавить в `leadDate.ts` функцию `firstLeadDate(now = new Date()): string` — порог 18:00 МСК:

```ts
export function firstLeadDate(now: Date = new Date()): string {
    // МСК = UTC+3, без перехода на летнее (РФ круглый год +3).
    const msk = new Date(now.getTime() + 3 * 3600 * 1000);
    const hourMsk = msk.getUTCHours();
    const addDays = hourMsk >= 18 ? 2 : 1;
    const target = new Date(Date.UTC(msk.getUTCFullYear(), msk.getUTCMonth(), msk.getUTCDate() + addDays));
    return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', timeZone: 'UTC' }).format(target);
}
```

  Тест в `leadDate.spec.ts`:

```ts
import { firstLeadDate } from '../../resources/js/utils/leadDate';
it('firstLeadDate: до 18:00 МСК → завтра', () => {
  // 2026-06-22 12:00 UTC = 15:00 МСК → завтра 23 июня
  expect(firstLeadDate(new Date('2026-06-22T12:00:00Z'))).toBe('23 июня');
});
it('firstLeadDate: после 18:00 МСК → послезавтра', () => {
  // 2026-06-22 16:00 UTC = 19:00 МСК → послезавтра 24 июня
  expect(firstLeadDate(new Date('2026-06-22T16:00:00Z'))).toBe('24 июня');
});
```

- [ ] **Step 2: Run — fails.**

- [ ] **Step 3: Implement** в `NewProjectDialog.vue`:
  - script: `import { firstLeadDate } from '../../utils/leadDate';` + `const leadStart = computed(() => firstLeadDate());`
  - template: в `<v-card-text>` ПЕРЕД `<v-tabs>` (только в режиме создания):

```html
<v-alert v-if="mode !== 'edit'" type="info" variant="tonal" density="comfortable"
         class="mb-4" data-testid="np-lead-banner">
    📣 Лидерра поставит проект в сбор сразу после создания. Первые лиды пойдут с {{ leadStart }}.
</v-alert>
```

- [ ] **Step 4: Failing→passing test** `NewProjectDialog.spec.ts`:

```ts
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { describe, it, expect } from 'vitest';
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';

describe('NewProjectDialog banner', () => {
  const vuetify = createVuetify();
  it('shows lead-start banner in create mode', () => {
    const w = mount(NewProjectDialog, { props: { modelValue: true, mode: 'create' },
      global: { plugins: [vuetify], stubs: { DevIndexBadge: true } } });
    expect(w.find('[data-testid="np-lead-banner"]').exists()).toBe(true);
    expect(w.get('[data-testid="np-lead-banner"]').text()).toContain('Первые лиды пойдут с');
  });
  it('hides banner in edit mode', () => {
    const w = mount(NewProjectDialog, { props: { modelValue: true, mode: 'edit' },
      global: { plugins: [vuetify], stubs: { DevIndexBadge: true } } });
    expect(w.find('[data-testid="np-lead-banner"]').exists()).toBe(false);
  });
});
```

  Run `npm run test:vue -- NewProjectDialog` → PASS. (Если props формы обязательны — добавить минимальные stubs/props по факту.)

- [ ] **Step 5: Commit.**

---

## Task 7: Полные прогоны + сборка

- [ ] **Step 1:** `cd app && ./vendor/bin/pest tests/Unit/Project tests/Feature/Http/Resources` → зелёные.
- [ ] **Step 2:** `npm run test:vue -- ProjectDetailsDrawer NewProjectDialog leadDate` → зелёные (exit 0 — показать).
- [ ] **Step 3:** `npm run type-check` (vue-tsc) → 0 ошибок.
- [ ] **Step 4:** `npm run build` → успех (запомнить имя нового бандла).
- [ ] **Step 5: Commit** (если что-то поправилось).

---

## Task 8: ВИЗУАЛЬНАЯ ПРОВЕРКА НА ЛОКАЛКЕ (глазами, обязательно перед прод)

- [ ] Поднять локальный портал (php artisan serve + собранный фронт ИЛИ vite dev), залогиниться тест-клиентом с активным supplier-связанным проектом (или засидить).
- [ ] Playwright: открыть проект в drawer → убедиться: поля источника **серые/disabled**, видна подсказка с замком и датой; поля лимит/регионы/дни — редактируемы.
- [ ] Открыть «Создать проект» → виден баннер «Первые лиды пойдут с {дата}».
- [ ] Снять скриншоты, посмотреть глазами: «работает и летает», вёрстка не поехала.
- [ ] **GATE:** если что-то криво — СТОП, не катить на прод, оставить отчёт.

---

## Task 9: ДЕПЛОЙ НА ПРОД (runbook gitea-prod-deploy-pipeline)

- [ ] Закоммитить всё, запушить в gitea (через owner-escape).
- [ ] Деплой по runbook: backup → clone gitea → **npm build (фронт менялся)** → maintenance down → rsync overlay → `optimize` под www-data → (миграций нет) → restart php8.3-fpm → up.
- [ ] Проверить HTTP 200, новый бандл, config owner=www-data, php-fpm active, 0 новых ошибок в логе.

---

## Task 10: ВИЗУАЛЬНАЯ ПРОВЕРКА НА ПРОДЕ + финал

- [ ] Playwright по liderra.ru: drawer активного проекта → поле источника заблокировано + подсказка с датой; «Создать проект» → баннер.
- [ ] Глазами: всё на месте, не сломалось.
- [ ] Обновить `ПИЛОТ.md` (снимок выката) + память (указатель на спеку/план + что выкачено).
- [ ] Прибрать временные файлы (скриншоты/preview/.playwright-mcp), не оставить хвостов.
- [ ] Короткий отчёт владельцу: что сделано, тесты, скриншоты, прод-проверка.
