# 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).
