Files
portal/docs/superpowers/plans/2026-06-22-project-source-edit-lock-ux.md
T

446 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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), не оставить хвостов.
- [ ] Короткий отчёт владельцу: что сделано, тесты, скриншоты, прод-проверка.