21 KiB
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
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.phpExpected: FAIL (Call to undefined method lockState). -
Step 3: Implement — add method to
SupplierSnapshotGuard(НЕ трогаяisProtected/computeGraceUntil/appliesFrom):
/**
* 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.phpExpected: 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
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, и добавить ключи:
$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,
);
и в массив:
'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
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:
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 добавить:
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):
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:
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-senderscombobox,pdd-sms-keywordinput) добавить:disabled="sourceLocked"(для combobox —:disabled, для input —:disabled). После блока источника (внутри каждой signal-ветки или один общий блок под ними) добавить:
<div v-if="sourceLocked" class="pdd-lock-hint" data-testid="pdd-source-lock-hint">
🔒 {{ sourceLockHint }}
</div>
- Стиль (в
<style scoped>):
.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 МСК:
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:
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>(только в режиме создания):
- script:
<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:
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), не оставить хвостов.
- Короткий отчёт владельцу: что сделано, тесты, скриншоты, прод-проверка.