Files
portal/docs/superpowers/plans/2026-06-22-project-source-edit-lock-ux.md
T
2026-06-24 05:57:16 +03:00

21 KiB
Raw Blame History

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.php Expected: 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.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
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-senders combobox, pdd-sms-keyword input) добавить :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> (только в режиме создания):
<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), не оставить хвостов.
  • Короткий отчёт владельцу: что сделано, тесты, скриншоты, прод-проверка.