docs(plan): Sprint 3B dashboard & deep-links implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,823 @@
|
||||
# Sprint 3B — Dashboard & Deep-links Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Закрыть аудит-эпики C1+J3 (живой дашборд через новый backend-эндпоинт) и C8+F3 (deep-link `/deals?openId=` из напоминаний и колокольчика).
|
||||
|
||||
**Architecture:** J3 — новый `DashboardController::summary` с агрегацией по `deals` + `tenants` (RLS-обёртка `SET LOCAL app.current_tenant_id`, паттерн `DealController`). C1 — `DashboardView` фетчит endpoint и пробрасывает данные в уже-prop-driven компоненты (`DashboardKpiRow`/`DashboardBalance`/`ActivityChart`/`FunnelChart`), при ошибке — fallback на mock. C8/F3 — три точки навигации переводятся на `query: { openId }`, а `DealsView` читает `route.query.openId` и открывает drawer найденной сделки.
|
||||
|
||||
**Tech Stack:** PHP 8.3 + Laravel 13, PostgreSQL 16 (партиционированная `deals`), Pest 4; Vue 3 `<script setup>` + Vuetify 3, vue-router 4, Vitest 4 + @vue/test-utils, TypeScript.
|
||||
|
||||
**Source:** [portal-wide audit spec §3 Sprint 3](../specs/2026-05-15-portal-audit-design.md) — эпики C1, J3, C8, F3.
|
||||
|
||||
**Branch:** feature-ветка от origin/main `65381f2` (Sprint 3A уже запушен). Не пушить без явного запроса заказчика.
|
||||
|
||||
---
|
||||
|
||||
## Контекст и факты recon
|
||||
|
||||
- **J3 — эндпоинта нет.** `routes/web.php` имеет только `Route::view('/dashboard','welcome')`; `DashboardController` отсутствует. Паттерн контроллера — `DealController` (`app/app/Http/Controllers/Api/DealController.php`): `DB::transaction` + `DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId)`, `tenant_id` query-параметром (MVP, без auth-middleware), defense-in-depth `where('tenant_id', …)`.
|
||||
- **Схема (`db/schema.sql`):** `deals` — `tenant_id`, `project_id`, `status` (slug), `received_at TIMESTAMPTZ` (ключ партиционирования), `is_test BOOLEAN`, `deleted_at` (soft-delete). `tenants` — `balance_rub DECIMAL(12,2)`, `balance_leads INT`, `limits JSONB` (`{"max_projects":10,...}`). Статус оплаты — slug `paid`.
|
||||
- **`Project` модель** — scope `active()` = `whereNull('archived_at')` (НЕ фильтрует `is_active`). «Активные проекты» дашборда = `archived_at IS NULL AND is_active = true`.
|
||||
- **C1 — все 4 dashboard-компонента уже prop-driven:** `DashboardKpiRow` (prop `kpis: Kpi[]`, тип экспортируется из компонента), `DashboardBalance` (prop `balance: Balance`, тип экспортируется), `ActivityChart` (props `points: number[]`, `labels: string[]`, `max: number`), `FunnelChart` (prop `counts: Record<string, number>`). DashboardView их не трогает — только фетч + проброс.
|
||||
- **C8** — `RemindersView.vue:70-73`: `openDeal(dealId)` → `void dealId; router.push('/deals')` (явно «на MVP без deep-link»).
|
||||
- **F3** — `AppTopbar.vue:56-62`: `handleNotificationClick(id, dealId)` → `markRead` + `if (dealId !== null) router.push('/deals')`. Notification имеет `deal_id: number | null` (`api/notifications.ts:26`).
|
||||
- **Deep-link consumer** — `DealsView.vue` НЕ читает `route.query`. Имеет `openDeal(deal: MockDeal)` → `selectedDeal.value = deal; drawerOpen.value = true`. `dealsState` грузится async (`loadDeals`, limit 200). `useRoute` сейчас не импортируется.
|
||||
- Тесты: backend — `app/tests/Feature/**/*.php` (Pest); frontend — `app/tests/Frontend/**/*.spec.ts` (Vitest). Команды backend — из `app/` (`composer test`, `php artisan test`); frontend — из `app/` (`npx vitest run …`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Ответственность | Действие |
|
||||
|---|---|---|
|
||||
| `app/app/Http/Controllers/Api/DashboardController.php` | агрегат дашборда | Create (Task 1) |
|
||||
| `app/routes/web.php` | маршрут `/api/dashboard/summary` | Modify (Task 1) |
|
||||
| `app/tests/Feature/DashboardSummaryTest.php` | Pest-тесты эндпоинта | Create (Task 1) |
|
||||
| `app/resources/js/api/dashboard.ts` | API-клиент дашборда | Create (Task 2) |
|
||||
| `app/resources/js/views/DashboardView.vue` | фетч + проброс props | Modify (Task 2) |
|
||||
| `app/tests/Frontend/DashboardView.spec.ts` | тесты DashboardView | Modify (Task 2) |
|
||||
| `app/resources/js/views/DealsView.vue` | чтение `route.query.openId` → drawer | Modify (Task 3) |
|
||||
| `app/resources/js/views/RemindersView.vue` | deep-link openDeal | Modify (Task 3) |
|
||||
| `app/resources/js/components/layout/AppTopbar.vue` | deep-link bell | Modify (Task 3) |
|
||||
| `app/tests/Frontend/DealsView.spec.ts` | тест openId-drawer | Modify (Task 3) |
|
||||
| `app/tests/Frontend/RemindersView.spec.ts` | тест deep-link | Modify (Task 3) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: J3 — backend `GET /api/dashboard/summary`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Http/Controllers/Api/DashboardController.php`
|
||||
- Modify: `app/routes/web.php`
|
||||
- Test: `app/tests/Feature/DashboardSummaryTest.php`
|
||||
|
||||
**Endpoint contract** — `GET /api/dashboard/summary?tenant_id=N&range=today|7d|30d` (range default `7d`):
|
||||
|
||||
```json
|
||||
{
|
||||
"range": "7d",
|
||||
"leads_received": { "value": 247, "delta_pct": 12.3, "delta_dir": "up" },
|
||||
"conversion": { "value": 18.4, "delta_pp": 2.1, "delta_dir": "up" },
|
||||
"active_projects":{ "active": 8, "limit": 10 },
|
||||
"balance": { "amount_rub": "14250.00", "runway_days": 4, "runway_leads": 285 },
|
||||
"activity": { "points": [3,5,2,8,6,9,4], "labels": ["сб","вс","пн","вт","ср","чт","сегодня"], "max": 10 },
|
||||
"funnel": { "new": 18, "paid": 45, ... }
|
||||
}
|
||||
```
|
||||
|
||||
`delta_dir` ∈ `up|down|neutral`. Окна: `today` = [startOfDay, now], `7d` = [now−7d, now], `30d` = [now−30d, now]; предыдущее окно — равной длины непосредственно перед текущим. Все агрегаты — `tenant_id` + `deleted_at IS NULL` + `is_test = false`. `funnel` — текущий снимок (вне окна). `runway_leads` = `tenants.balance_leads`; `runway_days` = `floor(balance_leads / avgDailyLeads7d)` (avgDaily = leads за 7д / 7; при avgDaily=0 → 0). `activity` — 7 daily-бакетов по `received_at` в MSK; `max` = `max(10, ceil(maxPoint/10)*10)`.
|
||||
|
||||
- [ ] **Step 1: Изучить factory-паттерн существующих Feature-тестов**
|
||||
|
||||
Прочитать один существующий тест в `app/tests/Feature/` который создаёт `Tenant` + `Deal` (например любой `Deal*Test.php` или supplier-тест) — зафиксировать, как создаются tenant/project/deal (фабрики `Tenant::factory()`, `Project::factory()`, `Deal::factory()` или прямые `::create`), как тест выставляет `received_at`/`status`, и как пользуется RLS (`postgres` superuser на dev — BYPASSRLS). Тест Task 1 должен использовать ровно тот же механизм. Это не плейсхолдер — это обязательная сверка фактического API фабрик перед написанием теста.
|
||||
|
||||
- [ ] **Step 2: Написать падающий Pest-тест**
|
||||
|
||||
Создать `app/tests/Feature/DashboardSummaryTest.php`. Минимум 6 тест-кейсов (синтаксис Pest mirror `tests/Feature/`-паттерна из Step 1):
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
|
||||
// helper: создать сделку с заданными status/received_at для тенанта/проекта.
|
||||
// Реализовать через фабрику/способ, зафиксированный в Step 1.
|
||||
|
||||
it('422 без tenant_id', function () {
|
||||
$this->getJson('/api/dashboard/summary')->assertStatus(422);
|
||||
});
|
||||
|
||||
it('404 для несуществующего тенанта', function () {
|
||||
$this->getJson('/api/dashboard/summary?tenant_id=999999')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('возвращает структуру summary с range по умолчанию 7d', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10], 'balance_rub' => '14250.00', 'balance_leads' => 285]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('range', '7d')
|
||||
->assertJsonStructure([
|
||||
'range',
|
||||
'leads_received' => ['value', 'delta_pct', 'delta_dir'],
|
||||
'conversion' => ['value', 'delta_pp', 'delta_dir'],
|
||||
'active_projects' => ['active', 'limit'],
|
||||
'balance' => ['amount_rub', 'runway_days', 'runway_leads'],
|
||||
'activity' => ['points', 'labels', 'max'],
|
||||
'funnel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leads_received считает только сделки окна, без deleted и is_test', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(2));
|
||||
makeDeal($tenant, $project, 'paid', now()->subDays(3));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1), deletedAt: now());
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(8));
|
||||
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}&range=7d")
|
||||
->assertOk()
|
||||
->assertJsonPath('leads_received.value', 3);
|
||||
});
|
||||
|
||||
it('conversion = доля статуса paid в окне', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
// 1 paid из 4 → 25.0%
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('conversion.value', 25.0);
|
||||
});
|
||||
|
||||
it('active_projects считает archived_at IS NULL AND is_active=true + limit из limits', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now(), 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => null, 'is_active' => false]);
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('active_projects.active', 2)
|
||||
->assertJsonPath('active_projects.limit', 10);
|
||||
});
|
||||
|
||||
it('funnel группирует живые сделки по статусу', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('funnel.new', 2)
|
||||
->assertJsonPath('funnel.paid', 1);
|
||||
});
|
||||
|
||||
it('activity возвращает 7 точек и 7 меток', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonCount(7, 'activity.points')
|
||||
->assertJsonCount(7, 'activity.labels');
|
||||
});
|
||||
```
|
||||
|
||||
Реализовать `makeDeal($tenant, $project, $status, $receivedAt, $deletedAt = null, $isTest = false)` как локальный helper в файле теста, опираясь на фабрику из Step 1. `deals` партиционирована по `received_at` — убедиться, что партиция для тестовых дат существует (если тестовый bootstrap её не создаёт — использовать `received_at` в пределах мая-июня 2026, для которых партиции в `schema.sql` уже есть, либо вызвать `partitions:create-months`).
|
||||
|
||||
- [ ] **Step 3: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run (из `app/`): `php artisan test --filter=DashboardSummaryTest`
|
||||
Expected: FAIL — маршрут `/api/dashboard/summary` не существует (404 на всех кейсах).
|
||||
|
||||
- [ ] **Step 4: Добавить маршрут**
|
||||
|
||||
В `app/routes/web.php` рядом с другими `/api/*` (например после блока deals) добавить:
|
||||
|
||||
```php
|
||||
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). На MVP без
|
||||
// auth-middleware (tenant_id параметром); production: middleware('auth:sanctum','tenant').
|
||||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Реализовать DashboardController**
|
||||
|
||||
Создать `app/app/Http/Controllers/Api/DashboardController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Дашборд — агрегат для DashboardView (audit C1/J3).
|
||||
*
|
||||
* GET /api/dashboard/summary?tenant_id={id}&range=today|7d|30d
|
||||
*
|
||||
* На MVP без auth-middleware (tenant_id параметром, как DealController).
|
||||
* Production: middleware('auth:sanctum','tenant') → tenant_id из user.
|
||||
*
|
||||
* Все агрегаты — tenant-scoped, deleted_at IS NULL, is_test=false.
|
||||
* RLS-обёртка SET LOCAL app.current_tenant_id (PgBouncer-safe), как DealController.
|
||||
*/
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
private const RU_WEEKDAYS = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
|
||||
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
$range = in_array($request->query('range'), ['today', '7d', '30d'], true)
|
||||
? (string) $request->query('range')
|
||||
: '7d';
|
||||
|
||||
$now = CarbonImmutable::now();
|
||||
[$windowStart, $prevStart] = match ($range) {
|
||||
'today' => [$now->startOfDay(), $now->startOfDay()->subDay()],
|
||||
'30d' => [$now->subDays(30), $now->subDays(60)],
|
||||
default => [$now->subDays(7), $now->subDays(14)],
|
||||
};
|
||||
|
||||
$data = DB::transaction(function () use ($tenantId, $tenant, $now, $range, $windowStart, $prevStart) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$base = fn () => DB::table('deals')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_test', false);
|
||||
|
||||
// --- leads received: текущее + предыдущее окно ---
|
||||
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
|
||||
// --- conversion: % статуса 'paid' в окне ---
|
||||
$curPaid = (clone $base())->where('status', 'paid')
|
||||
->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevPaid = (clone $base())->where('status', 'paid')
|
||||
->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
|
||||
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
|
||||
|
||||
// --- active projects ---
|
||||
$activeProjects = DB::table('projects')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('archived_at')
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
|
||||
|
||||
// --- activity: 7 daily-бакетов по received_at (MSK) ---
|
||||
$activityStart = $now->subDays(6)->startOfDay();
|
||||
$byDay = (clone $base())
|
||||
->where('received_at', '>=', $activityStart)
|
||||
->selectRaw("to_char((received_at AT TIME ZONE 'Europe/Moscow')::date, 'YYYY-MM-DD') AS d, COUNT(*) AS c")
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$points = [];
|
||||
$labels = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$day = $now->subDays($i);
|
||||
$key = $day->format('Y-m-d');
|
||||
$points[] = (int) ($byDay[$key] ?? 0);
|
||||
$labels[] = $i === 0 ? 'сегодня' : self::RU_WEEKDAYS[(int) $day->format('w')];
|
||||
}
|
||||
$maxPoint = $points === [] ? 0 : max($points);
|
||||
$axisMax = max(10, (int) (ceil($maxPoint / 10) * 10));
|
||||
|
||||
// --- funnel: текущий снимок по статусам ---
|
||||
$funnel = (clone $base())
|
||||
->selectRaw('status, COUNT(*) AS c')
|
||||
->groupBy('status')
|
||||
->pluck('c', 'status')
|
||||
->map(fn ($c) => (int) $c)
|
||||
->toArray();
|
||||
|
||||
// --- runway ---
|
||||
$avgDaily = $curLeads / 7.0; // средний дневной приток за 7д окно
|
||||
$balanceLeads = (int) ($tenant->balance_leads ?? 0);
|
||||
$runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0;
|
||||
|
||||
return [
|
||||
'range' => $range,
|
||||
'leads_received' => self::deltaBlock($curLeads, $prevLeads, 'delta_pct', self::pctDelta($curLeads, $prevLeads)),
|
||||
'conversion' => self::deltaBlock($curConv, $prevConv, 'delta_pp', round($curConv - $prevConv, 1)),
|
||||
'active_projects' => ['active' => $activeProjects, 'limit' => $maxProjects],
|
||||
'balance' => [
|
||||
'amount_rub' => (string) $tenant->balance_rub,
|
||||
'runway_days' => $runwayDays,
|
||||
'runway_leads' => $balanceLeads,
|
||||
],
|
||||
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
|
||||
'funnel' => (object) $funnel,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
/** Процентная дельта current vs previous; 0.0 если previous=0. */
|
||||
private static function pctDelta(float $cur, float $prev): float
|
||||
{
|
||||
return $prev > 0 ? round(($cur - $prev) / $prev * 100, 1) : 0.0;
|
||||
}
|
||||
|
||||
/** Блок {value, <deltaKey>, delta_dir}. */
|
||||
private static function deltaBlock(float $value, float $prev, string $deltaKey, float $delta): array
|
||||
{
|
||||
$dir = $value > $prev ? 'up' : ($value < $prev ? 'down' : 'neutral');
|
||||
|
||||
return ['value' => $value, $deltaKey => $delta, 'delta_dir' => $dir];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run (из `app/`): `php artisan test --filter=DashboardSummaryTest`
|
||||
Expected: PASS — все ≥7 кейсов зелёные. Если падает на партициях `deals` — перенести тестовые `received_at` в существующий партиционный диапазон или прогнать `php artisan partitions:create-months`.
|
||||
|
||||
- [ ] **Step 7: Проверка стиля и статанализа**
|
||||
|
||||
Run (из `app/`): `composer pint` и `composer stan`
|
||||
Expected: Pint — без правок (или авто-fix применён), Larastan — 0 ошибок (при новых false-positive по `Request::query` — добавить запись в `phpstan-baseline.neon` через `--generate-baseline`, как принято в проекте).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/DashboardController.php app/routes/web.php app/tests/Feature/DashboardSummaryTest.php app/phpstan-baseline.neon
|
||||
git commit -m "feat(dashboard): GET /api/dashboard/summary — агрегат KPI/баланса/активности (audit J3)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
(`phpstan-baseline.neon` добавлять только если он реально изменён.) Lefthook pre-commit прогонит pint/larastan/squawk/gitleaks — если упадёт, чинить причину, не `--no-verify`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: C1 — DashboardView на real API
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/api/dashboard.ts`
|
||||
- Modify: `app/resources/js/views/DashboardView.vue`
|
||||
- Test: `app/tests/Frontend/DashboardView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест API-клиента + view**
|
||||
|
||||
Создать `app/resources/js/api/dashboard.ts` пока НЕ создаём — сначала тест. Полностью переписать `app/tests/Frontend/DashboardView.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import DashboardView from '../../resources/js/views/DashboardView.vue';
|
||||
import type { DashboardSummary } from '../../resources/js/api/dashboard';
|
||||
|
||||
vi.mock('../../resources/js/api/dashboard', () => ({
|
||||
getDashboardSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
const dashboardApi = await import('../../resources/js/api/dashboard');
|
||||
|
||||
function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummary {
|
||||
return {
|
||||
range: '7d',
|
||||
leads_received: { value: 247, delta_pct: 12.3, delta_dir: 'up' },
|
||||
conversion: { value: 18.4, delta_pp: 2.1, delta_dir: 'up' },
|
||||
active_projects: { active: 8, limit: 10 },
|
||||
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
|
||||
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
|
||||
funnel: { new: 18, paid: 45 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mountView = () => {
|
||||
setActivePinia(createPinia());
|
||||
return mount(DashboardView, { global: { plugins: [createVuetify()] } });
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('DashboardView.vue ↔ /api/dashboard/summary', () => {
|
||||
it('getDashboardSummary вызывается на mount', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(makeSummary());
|
||||
mountView();
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('успех — KPI и баланс из API видны', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
|
||||
makeSummary({ balance: { amount_rub: '99000.00', runway_days: 9, runway_leads: 500 } }),
|
||||
);
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Получено лидов');
|
||||
expect(text).toContain('Конверсия в оплату');
|
||||
expect(text).toContain('Активные проекты');
|
||||
expect(text).toContain('Баланс');
|
||||
expect(text).toContain('99 000');
|
||||
});
|
||||
|
||||
it('ошибка API — fallback на mock, view не падает', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockRejectedValueOnce(new Error('500'));
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Получено лидов');
|
||||
expect(wrapper.find('.runway-fill').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('смена range перезапрашивает summary', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValue(makeSummary());
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
|
||||
(wrapper.vm as unknown as { range: string }).range = '30d';
|
||||
await flushPromises();
|
||||
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что падает**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/DashboardView.spec.ts`
|
||||
Expected: FAIL — `api/dashboard.ts` не существует (import не резолвится).
|
||||
|
||||
- [ ] **Step 3: Создать API-клиент**
|
||||
|
||||
Создать `app/resources/js/api/dashboard.ts`:
|
||||
|
||||
```ts
|
||||
import { apiClient } from './client';
|
||||
|
||||
/**
|
||||
* API-клиент дашборда (audit C1/J3). Эндпоинт GET /api/dashboard/summary.
|
||||
* На MVP без auth — tenant_id параметром (на prod возьмётся из middleware).
|
||||
*/
|
||||
|
||||
export type DeltaDir = 'up' | 'down' | 'neutral';
|
||||
export type DashboardRange = 'today' | '7d' | '30d';
|
||||
|
||||
export interface DashboardSummary {
|
||||
range: string;
|
||||
leads_received: { value: number; delta_pct: number; delta_dir: DeltaDir };
|
||||
conversion: { value: number; delta_pp: number; delta_dir: DeltaDir };
|
||||
active_projects: { active: number; limit: number };
|
||||
balance: { amount_rub: string; runway_days: number; runway_leads: number };
|
||||
activity: { points: number[]; labels: string[]; max: number };
|
||||
funnel: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function getDashboardSummary(tenantId: number, range: DashboardRange): Promise<DashboardSummary> {
|
||||
const { data } = await apiClient.get<DashboardSummary>('/api/dashboard/summary', {
|
||||
params: { tenant_id: tenantId, range },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Переписать DashboardView на фетч + проброс**
|
||||
|
||||
Заменить `<script setup>` в `app/resources/js/views/DashboardView.vue` (template/charts row остаются; KPI-row и balance теперь из reactive-state). Полный новый `<script setup>`:
|
||||
|
||||
```ts
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Дашборд — стартовая страница. Audit C1/J3: KPI/баланс/активность/воронка
|
||||
* грузятся из GET /api/dashboard/summary; при ошибке — fallback на mock,
|
||||
* чтобы UI оставался работоспособным (dev / отсутствие backend).
|
||||
*/
|
||||
import { ref, watch } from 'vue';
|
||||
import ActivityChart from '../components/charts/ActivityChart.vue';
|
||||
import FunnelChart from '../components/charts/FunnelChart.vue';
|
||||
import DashboardPageHead from '../components/dashboard/DashboardPageHead.vue';
|
||||
import DashboardKpiRow, { type Kpi } from '../components/dashboard/DashboardKpiRow.vue';
|
||||
import DashboardBalance, { type Balance } from '../components/dashboard/DashboardBalance.vue';
|
||||
import { getDashboardSummary, type DashboardRange, type DashboardSummary } from '../api/dashboard';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const range = ref<DashboardRange | 'custom'>('7d');
|
||||
|
||||
// runwayMax — display-константа полосы (7 сегментов), не из API.
|
||||
const RUNWAY_MAX = 7;
|
||||
|
||||
// Mock-fallback — UI работоспособен без backend (dev / 500 / нет auth).
|
||||
const MOCK_KPIS: Kpi[] = [
|
||||
{ label: 'Получено лидов', value: '247', delta: { dir: 'up', text: '12.3%' }, sub: 'vs предыдущий период' },
|
||||
{ label: 'Конверсия в оплату', value: '18.4', unit: '%', delta: { dir: 'up', text: '2.1pp' }, sub: 'vs предыдущий период' },
|
||||
{ label: 'Активные проекты', value: '8', unit: '/ 10', delta: { dir: 'neutral', text: '' }, sub: 'лимит тарифа' },
|
||||
];
|
||||
const MOCK_BALANCE: Balance = { amount: '14 250', runwayDays: 4, runwayMax: RUNWAY_MAX, runwayLeads: 285 };
|
||||
|
||||
const kpis = ref<Kpi[]>(MOCK_KPIS);
|
||||
const balance = ref<Balance>(MOCK_BALANCE);
|
||||
const activityPoints = ref<number[]>([16, 31, 27, 47, 39, 56, 50]);
|
||||
const activityLabels = ref<string[]>(['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'сегодня']);
|
||||
const activityMax = ref(60);
|
||||
const funnelCounts = ref<Record<string, number> | undefined>(undefined);
|
||||
const fetchError = ref(false);
|
||||
|
||||
/** Форматирует число с пробелами-разделителями тысяч ('14250.00' → '14 250'). */
|
||||
function formatRub(raw: string): string {
|
||||
const int = Math.round(parseFloat(raw)).toString();
|
||||
return int.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
}
|
||||
|
||||
function applySummary(s: DashboardSummary): void {
|
||||
kpis.value = [
|
||||
{
|
||||
label: 'Получено лидов',
|
||||
value: String(s.leads_received.value),
|
||||
delta: { dir: s.leads_received.delta_dir, text: `${s.leads_received.delta_pct}%` },
|
||||
sub: 'vs предыдущий период',
|
||||
},
|
||||
{
|
||||
label: 'Конверсия в оплату',
|
||||
value: String(s.conversion.value),
|
||||
unit: '%',
|
||||
delta: { dir: s.conversion.delta_dir, text: `${s.conversion.delta_pp}pp` },
|
||||
sub: 'vs предыдущий период',
|
||||
},
|
||||
{
|
||||
label: 'Активные проекты',
|
||||
value: String(s.active_projects.active),
|
||||
unit: `/ ${s.active_projects.limit}`,
|
||||
delta: { dir: 'neutral', text: '' },
|
||||
sub: 'лимит тарифа',
|
||||
},
|
||||
];
|
||||
balance.value = {
|
||||
amount: formatRub(s.balance.amount_rub),
|
||||
runwayDays: Math.min(s.balance.runway_days, RUNWAY_MAX),
|
||||
runwayMax: RUNWAY_MAX,
|
||||
runwayLeads: s.balance.runway_leads,
|
||||
};
|
||||
activityPoints.value = s.activity.points;
|
||||
activityLabels.value = s.activity.labels;
|
||||
activityMax.value = s.activity.max;
|
||||
funnelCounts.value = s.funnel;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const tenantId = auth.user?.tenant_id;
|
||||
if (!tenantId || range.value === 'custom') return;
|
||||
try {
|
||||
applySummary(await getDashboardSummary(tenantId, range.value));
|
||||
fetchError.value = false;
|
||||
} catch {
|
||||
fetchError.value = true; // оставляем последнее значение / mock
|
||||
}
|
||||
}
|
||||
|
||||
watch(range, load);
|
||||
load();
|
||||
</script>
|
||||
```
|
||||
|
||||
Шаблон `<template>` менять минимально — заменить статичные `:kpis="kpis"` / `:balance="balance"` на reactive-ref'ы (Vue разворачивает `.value` в шаблоне автоматически, синтаксис `:kpis="kpis"` не меняется) и пробросить чарт-props + funnel + degradation-alert. Заменить блок `<v-row class="charts-row …">` на:
|
||||
|
||||
```html
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-3"
|
||||
data-testid="dashboard-fetch-error"
|
||||
>
|
||||
Не удалось обновить данные дашборда — показаны последние известные значения.
|
||||
</v-alert>
|
||||
|
||||
<v-row class="charts-row mt-4">
|
||||
<v-col cols="12" md="7">
|
||||
<ActivityChart :points="activityPoints" :labels="activityLabels" :max="activityMax" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="5">
|
||||
<FunnelChart :counts="funnelCounts" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
```
|
||||
|
||||
(`FunnelChart` prop `counts` опциональный — `undefined` оставит его mock-default; при успехе API передаст реальные counts.)
|
||||
|
||||
- [ ] **Step 5: Запустить тест — убедиться, что проходит**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/DashboardView.spec.ts`
|
||||
Expected: PASS — 4 теста зелёные.
|
||||
|
||||
- [ ] **Step 6: type-check + lint**
|
||||
|
||||
Run (из `app/`): `npm run type-check` и `npm run lint:vue`
|
||||
Expected: 0 ошибок. `ActivityChart` prop `points` non-optional при передаче — ок; `FunnelChart` `counts?: Record<string,number>` принимает `undefined`.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/api/dashboard.ts app/resources/js/views/DashboardView.vue app/tests/Frontend/DashboardView.spec.ts
|
||||
git commit -m "feat(dashboard): DashboardView на real API /api/dashboard/summary (audit C1)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: C8 + F3 — deep-link `/deals?openId=`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/DealsView.vue`
|
||||
- Modify: `app/resources/js/views/RemindersView.vue`
|
||||
- Modify: `app/resources/js/components/layout/AppTopbar.vue`
|
||||
- Test: `app/tests/Frontend/DealsView.spec.ts`, `app/tests/Frontend/RemindersView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающие тесты**
|
||||
|
||||
**1a.** В `app/tests/Frontend/DealsView.spec.ts` добавить тест: при `route.query.openId` совпадающем с id сделки в `dealsState` — drawer открыт. Использовать установленный в файле паттерн mount'а DealsView с роутером; если файл монтирует без роутера — добавить memory-router с маршрутом `/deals` и `push('/deals?openId=<id>')` до mount. Тест (адаптировать имена под фактический mount-helper файла):
|
||||
|
||||
```ts
|
||||
it('route.query.openId открывает drawer соответствующей сделки', async () => {
|
||||
// mount DealsView на /deals?openId=<существующий id из MOCK_DEALS>
|
||||
// после loadDeals() (flushPromises) — drawerOpen=true, selectedDeal.id === openId
|
||||
const openId = MOCK_DEALS[0].id;
|
||||
const wrapper = await mountDealsViewAt(`/deals?openId=${openId}`);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
|
||||
expect(vm.drawerOpen).toBe(true);
|
||||
expect(vm.selectedDeal?.id).toBe(openId);
|
||||
});
|
||||
|
||||
it('openId не найден среди сделок — drawer не открывается, без ошибки', async () => {
|
||||
const wrapper = await mountDealsViewAt('/deals?openId=99999999');
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean };
|
||||
expect(vm.drawerOpen).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
`drawerOpen` и `selectedDeal` добавить в `defineExpose` DealsView (Step 3).
|
||||
|
||||
**1b.** В `app/tests/Frontend/RemindersView.spec.ts` добавить тест: `openDeal(42)` вызывает `router.push` с `{ path: '/deals', query: { openId: 42 } }`. Использовать `vi.spyOn(router, 'push')` по паттерну файла.
|
||||
|
||||
- [ ] **Step 2: Запустить тесты — убедиться, что падают**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/DealsView.spec.ts tests/Frontend/RemindersView.spec.ts`
|
||||
Expected: FAIL — DealsView не реагирует на `openId`; RemindersView пушит `/deals` без query.
|
||||
|
||||
- [ ] **Step 3: DealsView — читать `route.query.openId`**
|
||||
|
||||
В `app/resources/js/views/DealsView.vue`:
|
||||
|
||||
**3a.** В импортах добавить `useRoute`:
|
||||
|
||||
```ts
|
||||
import { useRoute } from 'vue-router';
|
||||
```
|
||||
|
||||
и в `<script setup>` рядом с другими const'ами:
|
||||
|
||||
```ts
|
||||
const route = useRoute();
|
||||
```
|
||||
|
||||
**3b.** Добавить функцию открытия по id и вызвать её после загрузки. После `function openDeal(deal: MockDeal) { … }` добавить:
|
||||
|
||||
```ts
|
||||
/** Audit C8/F3: deep-link — открыть drawer сделки по ?openId= из URL. */
|
||||
function openDealFromQuery(): void {
|
||||
const raw = route.query.openId;
|
||||
const id = Number(Array.isArray(raw) ? raw[0] : raw);
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
const deal = dealsState.find((d) => d.id === id);
|
||||
if (deal) openDeal(deal);
|
||||
}
|
||||
```
|
||||
|
||||
**3c.** В `onMounted` — вызвать после загрузки. Заменить:
|
||||
|
||||
```ts
|
||||
onMounted(() => {
|
||||
void leadStatusesStore.load();
|
||||
void loadDeals();
|
||||
});
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
onMounted(async () => {
|
||||
void leadStatusesStore.load();
|
||||
await loadDeals();
|
||||
openDealFromQuery();
|
||||
});
|
||||
```
|
||||
|
||||
И реагировать на смену query (навигация на `/deals?openId=` когда DealsView уже смонтирован) — добавить watch рядом с другими watch'ами:
|
||||
|
||||
```ts
|
||||
watch(
|
||||
() => route.query.openId,
|
||||
() => openDealFromQuery(),
|
||||
);
|
||||
```
|
||||
|
||||
**3d.** В `defineExpose({ … })` добавить `drawerOpen`, `selectedDeal`, `openDealFromQuery` (для тестов).
|
||||
|
||||
- [ ] **Step 4: RemindersView — deep-link openDeal**
|
||||
|
||||
В `app/resources/js/views/RemindersView.vue` заменить:
|
||||
|
||||
```ts
|
||||
async function openDeal(dealId: number): Promise<void> {
|
||||
void dealId; // на MVP — без deep-link на конкретный drawer.
|
||||
await router.push('/deals');
|
||||
}
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
async function openDeal(dealId: number): Promise<void> {
|
||||
// Audit C8: deep-link на конкретный drawer через ?openId=.
|
||||
await router.push({ path: '/deals', query: { openId: dealId } });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: AppTopbar — deep-link bell**
|
||||
|
||||
В `app/resources/js/components/layout/AppTopbar.vue` заменить:
|
||||
|
||||
```ts
|
||||
async function handleNotificationClick(id: number, dealId: number | null): Promise<void> {
|
||||
await notifications.markRead(id);
|
||||
if (dealId !== null) {
|
||||
// На MVP — push на DealsView (deep-link на конкретный drawer — отдельный коммит).
|
||||
await router.push('/deals');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
async function handleNotificationClick(id: number, dealId: number | null): Promise<void> {
|
||||
await notifications.markRead(id);
|
||||
if (dealId !== null) {
|
||||
// Audit F3: deep-link на конкретный drawer через ?openId=.
|
||||
await router.push({ path: '/deals', query: { openId: dealId } });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Запустить тесты — убедиться, что проходят**
|
||||
|
||||
Run (из `app/`): `npx vitest run tests/Frontend/DealsView.spec.ts tests/Frontend/RemindersView.spec.ts`
|
||||
Expected: PASS — все тесты зелёные.
|
||||
|
||||
- [ ] **Step 7: Полный sweep**
|
||||
|
||||
Run (из `app/`): `npm run test:vue`, `npm run type-check`, `npm run lint:vue`
|
||||
Expected: Vitest 0 failed (выписать точные счётчики), type-check 0, lint 0.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/DealsView.vue app/resources/js/views/RemindersView.vue app/resources/js/components/layout/AppTopbar.vue app/tests/Frontend/DealsView.spec.ts app/tests/Frontend/RemindersView.spec.ts
|
||||
git commit -m "feat(deals): deep-link /deals?openId= из напоминаний и колокольчика (audit C8/F3)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- **J3:** `GET /api/dashboard/summary?tenant_id=N&range=…` возвращает контракт выше; Pest ≥7 кейсов зелёные; Pint/Larastan 0.
|
||||
- **C1:** DashboardView грузит summary на mount + при смене range; KPI/баланс/активность/воронка — из API; ошибка → degradation-alert + последние/mock-значения.
|
||||
- **C8/F3:** клик по напоминанию и по уведомлению-колокольчику с `deal_id` ведёт на `/deals?openId=<id>`; DealsView открывает drawer найденной сделки; openId не найден → no-op без ошибки.
|
||||
- `npm run test:vue` 0 failed; `npm run type-check` 0; `npm run lint:vue` 0; `php artisan test --filter=DashboardSummaryTest` 0 failed.
|
||||
- 3 атомарных коммита (J3 / C1 / C8+F3). Без push до явного запроса.
|
||||
|
||||
## Self-Review (выполнено при написании плана)
|
||||
|
||||
**Spec coverage:** C1 → Task 2; J3 → Task 1; C8 → Task 3 (RemindersView + DealsView consumer); F3 → Task 3 (AppTopbar + DealsView consumer). Все 4 ID Sprint 3B покрыты.
|
||||
|
||||
**Placeholder scan:** код приведён полностью. Единственная инструкция-без-кода — Task 1 Step 1 (изучить factory-паттерн существующих Feature-тестов) — это обязательная сверка фактического API фабрик, т.к. точный API `Deal::factory()` не в контексте автора плана; и Task 3 Step 1 (адаптировать mount-helper под фактический DealsView.spec) — оба требуют чтения одного reference-файла, не выдумывания.
|
||||
|
||||
**Type consistency:** `DashboardSummary` (api/dashboard.ts) ↔ контракт эндпоинта J3 ↔ `applySummary` в DashboardView совпадают по полям. `Kpi`/`Balance` импортируются из существующих компонентов без изменения. `getDashboardSummary(tenantId, range)` — единая сигнатура в клиенте, тесте и view.
|
||||
|
||||
**Риск:** партиционирование `deals` по `received_at` — тестовые даты должны попадать в существующие партиции (май-окт 2026 уже в schema.sql) либо `partitions:create-months` (отмечено в Task 1 Step 2/6).
|
||||
Reference in New Issue
Block a user