446 lines
21 KiB
Markdown
446 lines
21 KiB
Markdown
# 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), не оставить хвостов.
|
||
- [ ] Короткий отчёт владельцу: что сделано, тесты, скриншоты, прод-проверка.
|