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:
Дмитрий
2026-05-16 10:43:27 +03:00
parent 425d58f2a9
commit c5261a0b22
@@ -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` = [now7d, now], `30d` = [now30d, 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).