diff --git a/CLAUDE.md b/CLAUDE.md index 9cc195ae..2dcaf672 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.53 от 09.05.2026 +**Версия:** 1.54 от 09.05.2026 **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. > **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5). @@ -15,7 +15,7 @@ | Полный реестр 28 инструментов и фазы | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v1.7 от 08.05.2026 поздний вечер — Histoire 1.0-beta.1 активирован, фаза 2 по тулчейну закрыта 6/6, всего активно 18/28) | | Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_5.md](docs/CRM_bp-gr_Инструкция_v8_5.md) (v8.5 от 07.05.2026 — реализация 27 решений аудита C; in-place hygiene v1.20 от 08.05.2026 поздний вечер: §2.4/§5.5/§5.6/§6.5/§11/§20.12.3/§21.1/§27.1 синхронизированы под schema v8.6 двустадийный dedup) | | Схема БД | [db/schema.sql](db/schema.sql) (**v8.8 от 09.05.2026** — `users.totp_secret` VARCHAR(255) → TEXT для encrypted-cast. Метрики: 55 таблиц + 12 партиций + 92 индекса + 36 RLS + 5 функций + 13 триггеров) | -| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.62 от 09.05.2026 — **XLSX-export через PhpSpreadsheet**: phpoffice/phpspreadsheet 5.7; format=csv\|xlsx параметр на /api/deals/export; backend генерит .xlsx с bold-header + auto-size; `exportDealsXlsx` (Blob) на frontend'е; DealsView дефолтит на xlsx с CSV-fallback; Pest 197/197 + Vitest 269/269 + Histoire 21/28) | +| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.63 от 09.05.2026 — **GET /api/deals/{id} + DealDetailDrawer activity-timeline**: show-endpoint с deal+events; mapApiDealEvent converter; DealDetailDrawer fetch'ит на open + fallback на MOCK_EVENTS; Pest 205/205 + Vitest 273/273 + Histoire 21/28) | | **Брендбук** | [liderra_v8_handoff/docs/BRANDBOOK_v2.md](liderra_v8_handoff/docs/BRANDBOOK_v2.md) **(v2 Forest от 07.05.2026)** — старый `docs/brandbook.md` v1.1 удалён 08.05.2026 | | **Дизайн-handoff (токены, компоненты, 25 экранов)** | [liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md](liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md) (v8 Forest от 07.05.2026) — **только дизайн/токены/компоненты**; функционал и состав экранов — по ТЗ v8.5 | | Анализ оригинала | [docs/Analiz_originala_v8_3.md](docs/Analiz_originala_v8_3.md) (Прил. М v1.1) | @@ -224,6 +224,8 @@ trivy image liderra:latest --- +*CLAUDE.md v1.54 от 09.05.2026. Изменения v1.54: **GET /api/deals/{id} + интеграция DealDetailDrawer на реальный ActivityLog**. **(1) Backend `DealController::show(int $id)`** — возвращает `{deal, events}` для drawer'а. RLS-обёртка + defense-in-depth `where(tenant_id)` (как в index/transition); 404 если сделка чужая или не существует. `deal` — extended (project_name + manager_name/initials через `ManagerController::format*` + comment + assigned_at). `events` — последние **50** записей `activity_log` фильтрованных по `(tenant_id, deal_id)` ORDER BY created_at DESC, с актором (user через `belongsTo`-relation). Маршрут `Route::get('/api/deals/{id}', 'show')->where('id', '[0-9]+')`. **(2) Pest +8** в `tests/Feature/DealShowTest.php` (всего **205/205 за 24.19 сек**, 812 assertions): 422 без tenant_id / 404 unknown tenant / 404 несуществующая сделка / 404 чужая сделка (RLS-проверка через postgres superuser BYPASSRLS работает за счёт app-фильтра) / deal-relations (project_name + manager_name «Иван П.» + initials «ИП» + comment) / events ORDER BY created_at DESC (status_changed свежее createde) + actor.name + actor=null для system-event с user_id=null / RLS+app-фильтр НЕ показывает события с `deal_id` совпадающим у чужого tenant'а / лимит 50 событий (60 записей → возвращаем 50). **(3) Frontend `api/deals.ts::getDeal(id, tenantId)`** — типизированный helper с `ApiDealEvent`/`ApiDealDetail`/`GetDealResponse` interfaces; БЕЗ ensureCsrfCookie (GET-only). **`composables/dealsApiMapper.ts::mapApiDealEvent(api, now=new Date())`** — converter ApiDealEvent → DealEvent (UI-формат): `event` slug clamp на known types (`deal.{created,status_changed,viewed,commented,assigned,balance_charged}`) с fallback на `'deal.viewed'` (generic-icon); `actor` маппится 1:1; `minutesAgo = max(0, floor((now - created_at) / 60_000))`; `detail` зависит от type — для `status_changed` строим `«from → to»` из context, для `created` — `«Лид принят (источник: …)»`, для остальных — JSON-сводка контекста. **(4) DealDetailDrawer** получил optional `tenantId` prop. `watch([open, deal.id, tenantId])` с `immediate: true` — на open=true вызывает `loadEvents()`. Если оба (deal + tenantId) есть → `getDeal(deal.id, tenantId)` → `events.value = events.map(mapApiDealEvent)`. На fail → `eventsFetchError=true` + `v-alert type=warning «Backend недоступен — показаны mock-события»` (data-testid=`events-fetch-error-alert`) + fallback на `MOCK_EVENTS`. Без tenantId — никогда не fetch'им, MOCK_EVENTS как раньше. DealsView и KanbanView передают `:tenant-id="auth.user?.tenant_id"`. **(5) Vitest +4** в `DealDetailDrawerApi.spec.ts` (всего **273/273 за 20.76 сек**, +4 от 269): без tenantId — getDeal не вызывается + MOCK_EVENTS видны / с tenantId — getDeal вызывается + events заменены + «new → paid» виден / reject → eventsFetchError + alert + MOCK_EVENTS fallback / open=false → НЕ вызывается. PHPStan baseline регенерирован для +новых ignored Pest TestCall warnings. **Production TODO остаточные:** polling/SSE для real-time обновления (на MVP — manual reload-btn); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 273/273 за 20.76 сек** (+4 от 269); vite build 1.12 сек (KanbanView lazy-chunk 182.17→182.22 KB — DealDetailDrawer импорт `mapApiDealEvent` shared); Pint+PHPStan passed; **Pest 205/205 за 24.19 сек** (+8 от 197, 812 assertions). Реестр v1.62→v1.63.* + *CLAUDE.md v1.53 от 09.05.2026. Изменения v1.53: **XLSX-export через PhpSpreadsheet** — закрыт TODO «реальный XLSX-export» из v1.52. Установлен `phpoffice/phpspreadsheet:^5.0` (v5.7.0). Endpoint POST /api/deals/export расширен опциональным параметром `format` (default 'csv' для backward-compat, 'xlsx' = новая ветка). Backend `buildXlsx()`: `Spreadsheet` + `setTitle('Сделки')` + `setCellValue('A1'...G1')` для headers + `getStyle('A1:G1')->getFont()->setBold(true)` + `setAutoSize(true)` для всех колонок. `Xlsx` writer пишет в `php://output` через `ob_start/ob_get_clean` чтобы вернуть бинарную строку из контроллера. Content-Type `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` + Content-Disposition `attachment; filename="deals_export_YYYY-MM-DD.xlsx"`. **Quirk:** PhpSpreadsheet 5.x удалил deprecated-метод `setCellValueByColumnAndRow($col, $row, $val)` — пришлось мигрировать на A1-нотацию (`setCellValue('A2', $val)`). Обнаружено в первом тестовом прогоне (500 на endpoint'е), исправлено сразу. **Pest +4** в `DealCreateTest` (всего **197/197 за 26.05 сек**, 784 assertions): xlsx возвращает binary с правильным Content-Type + magic bytes "PK\x03\x04" (XLSX = ZIP) + размер >2KB; распаковка через PhpSpreadsheet IOFactory::createReader('Xlsx') → sheet `Сделки` + A1='ID' + B1='Имя' (bold=true) + A2/B2/C2 = реальные данные сделки; 422 на неизвестный format ('pdf'); по умолчанию (без format) — backward-compat CSV. **Frontend** — `api/deals.ts` разделён на 2 функции: `exportDeals` (CSV, returns string, ставит format='csv' в payload) + `exportDealsXlsx` (XLSX, returns Blob, responseType='blob' для axios). DealsView `applyBulkExport(format='xlsx')` async получил параметр format с default 'xlsx' (UX prefer Excel-friendly формат, особенно RU-локаль с 1С). XLSX-ветка вызывает `exportDealsXlsx` → `triggerBlobDownload(blob, filename)` (новый helper, отделён от `triggerCsvDownload` чтобы Blob не конструировался дважды); CSV-ветка через старый `exportDeals`/`triggerCsvDownload`. На fail → fallback на local CSV (даже если запросили xlsx — без backend'а xlsx не построим). **Vitest +3** в `DealsListIntegration.spec.ts` (всего **269/269 за 18.49 сек**): xlsx default вызывает `exportDealsXlsx` (НЕ `exportDeals`) + триггерит download через blob:url + toast «XLSX»; csv-вариант вызывает `exportDeals` (НЕ Xlsx) + toast «CSV»; xlsx reject → fallback на local CSV + toast «Backend недоступен». PHPStan baseline регенерирован. **Production TODO остаточные:** polling/SSE для real-time обновления (на MVP — manual reload-btn); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 269/269 за 18.49 сек** (+3 от 266); vite build 982 ms; Pint+PHPStan passed (baseline регенерирован); **Pest 197/197 за 26.05 сек** (+4 от 193, 784 assertions). Реестр v1.61→v1.62.* *CLAUDE.md v1.52 от 09.05.2026. Изменения v1.52: **Bulk-transition + reload-btn** — закрывает «UI меняет статус, но изменения не сохраняются на backend» gap из v1.51 + добавляет manual reload как замену polling/SSE до прихода long-poll'а. **(1) Backend `DealController::transition`** — POST /api/deals/transition `{tenant_id, ids: [int...], status: slug}`. Валидация: `ids` обязателен 1..1000 ints, `status` обязателен ≤50 chars + `DB::table('lead_statuses')->where('slug', X)->exists()` (422 «Slug не найден в lead_statuses» если нет). `lead_statuses` — глобальная таблица (НЕ tenant-aware), system+custom slug'и в одном scope. RLS-обёртка `SET LOCAL app.current_tenant_id` + defense-in-depth `where('tenant_id', $tenantId)->whereIn('id', $ids)` — на тестах postgres superuser обходит RLS, app-фильтр гарантирует что чужие id не апдейтятся (partial-update: `updated < requested` если часть id принадлежит другому tenant'у). `ActivityLog::create([event=deal.status_changed, context={from, to, source=bulk}])` для каждой ИЗМЕНЁННОЙ сделки (NO-OP — старый==новый — НЕ пишется в audit log, иначе спам при «обновить тот же статус»). Ответ: `{updated, requested, status}`. Маршрут `Route::post('/api/deals/transition')`. **(2) Pest +7** в `tests/Feature/DealTransitionTest.php` (всего **193/193 за 23.27 сек**, 767 assertions): 422 missing fields / 404 unknown tenant / 422 неизвестный slug + сделка не апдейтится / batch update 3 сделок + 3 ActivityLog с правильным context.from/to/source / NO-OP не пишет ActivityLog / defense-in-depth (передаём 2 id из разных tenant'ов — обновляется только свой, чужой остаётся в исходном статусе) / 422 пустой массив ids. **(3) Frontend `dealsApi.transitionDeals(payload)`** — типизированный helper, `ensureCsrfCookie` обязателен (mutating). **`applyBulkStatus` в DealsView** переписан с sync на async: optimistic local-update (UI отвечает сразу), затем backend-вызов если есть auth.user.tenant_id. На success — `statusToast «Обновлено N из M.»`. На fail — `«Не удалось сохранить статус — изменения только локально.»` + локальный update НЕ откатывается (UX rationale: пользователь видит что хотел, перезагрузит чуть позже; auto-rollback запутает больше чем поможет). Без auth.user — только optimistic, API не вызывается (legacy local-mode сохранён). **(4) Reload-btn** в DealsView и KanbanView — outlined button «Обновить» с mdi-refresh, привязан к `loadDeals` action. В DealsView у btn'а `:loading="loading"` chip — крутится во время fetch'а. **(5) Vitest +5** (всего **266/266 за 18.16 сек**): reload-btn в DealsView (listDeals вызывается дважды) + applyBulkStatus с tenant_id (transitionDeals вызывается + optimistic update до завершения + toast «Обновлено 2») + applyBulkStatus БЕЗ tenant_id (transitionDeals НЕ вызывается + только локально) + applyBulkStatus reject (toast warning + локальный update НЕ откатывается); reload-btn в KanbanView (тот же 2× listDeals). PHPStan baseline регенерирован. **Production TODO остаточные:** реальный XLSX-export через PhpSpreadsheet; polling/SSE для real-time (на MVP — manual reload); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 266/266 за 18.16 сек** (+5 от 261); vite build 1.06 сек (KanbanView lazy-chunk 181.98→182.17 KB — добавил `dealsApi.transitionDeals` импорт через DealsView, но KanbanView его не тянет напрямую — рост от reload-btn shared chunk); Pint+PHPStan passed; **Pest 193/193 за 23.27 сек** (+7 от 186, 767 assertions). Реестр v1.60→v1.61.* diff --git a/app/app/Http/Controllers/Api/DealController.php b/app/app/Http/Controllers/Api/DealController.php index 3e88a5ee..60516708 100644 --- a/app/app/Http/Controllers/Api/DealController.php +++ b/app/app/Http/Controllers/Api/DealController.php @@ -132,6 +132,92 @@ class DealController extends Controller ]); } + /** + * GET /api/deals/{id}?tenant_id={id} — детали сделки + recent activity events. + * + * Используется в DealDetailDrawer (правая панель). Возвращает deal с + * relations + до 50 последних activity_log событий по этой сделке. + * + * RLS-обёртка + defense-in-depth `where(tenant_id)`. Если сделка не + * принадлежит tenant'у (или не существует) — 404. + */ + public function show(Request $request, int $id): 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); + } + + [$deal, $events] = DB::transaction(function () use ($tenantId, $id) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + $deal = Deal::query() + ->where('tenant_id', $tenantId) + ->where('id', $id) + ->with(['project:id,name', 'manager:id,email,first_name,last_name']) + ->first(); + + if ($deal === null) { + return [null, []]; + } + + $events = ActivityLog::query() + ->where('tenant_id', $tenantId) + ->where('deal_id', $id) + ->with('user:id,email,first_name,last_name') + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit(50) + ->get(); + + return [$deal, $events]; + }); + + if ($deal === null) { + return response()->json(['message' => 'Сделка не найдена.'], 404); + } + + return response()->json([ + 'deal' => [ + 'id' => $deal->id, + 'tenant_id' => $deal->tenant_id, + 'project_id' => $deal->project_id, + 'project_name' => $deal->project?->name, + 'phone' => $deal->phone, + 'contact_name' => $deal->contact_name, + 'comment' => $deal->comment, + 'status' => $deal->status, + 'manager_id' => $deal->manager_id, + 'manager_name' => $deal->manager + ? ManagerController::formatName($deal->manager->first_name, $deal->manager->last_name, $deal->manager->email) + : null, + 'manager_initials' => $deal->manager + ? ManagerController::formatInitials($deal->manager->first_name, $deal->manager->last_name, $deal->manager->email) + : null, + 'received_at' => $deal->received_at?->toIso8601String(), + 'assigned_at' => $deal->assigned_at?->toIso8601String(), + ], + 'events' => $events->map(fn (ActivityLog $e) => [ + 'id' => $e->id, + 'event' => $e->event, + 'context' => $e->context, + 'created_at' => $e->created_at?->toIso8601String(), + 'actor' => $e->user + ? [ + 'id' => $e->user->id, + 'name' => ManagerController::formatName($e->user->first_name, $e->user->last_name, $e->user->email), + 'initials' => ManagerController::formatInitials($e->user->first_name, $e->user->last_name, $e->user->email), + ] + : null, + ]), + ]); + } + /** * POST /api/deals/transition — bulk status-update для выбранных сделок. * diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 642130db..e6af988d 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -3,7 +3,7 @@ parameters: - message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#' identifier: nullsafe.neverNull - count: 1 + count: 2 path: app/Http/Controllers/Api/DealController.php - @@ -288,6 +288,36 @@ parameters: count: 14 path: tests/Feature/DealIndexTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$manager\.$#' + identifier: property.notFound + count: 3 + path: tests/Feature/DealShowTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$otherTenant\.$#' + identifier: property.notFound + count: 6 + path: tests/Feature/DealShowTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' + identifier: property.notFound + count: 7 + path: tests/Feature/DealShowTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 20 + path: tests/Feature/DealShowTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 8 + path: tests/Feature/DealShowTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$otherTenant\.$#' identifier: property.notFound diff --git a/app/resources/js/api/deals.ts b/app/resources/js/api/deals.ts index a97e1d76..d9ff7207 100644 --- a/app/resources/js/api/deals.ts +++ b/app/resources/js/api/deals.ts @@ -96,6 +96,31 @@ export interface ApiDeal { received_at: string | null; } +export interface ApiDealEvent { + id: number; + event: string; + context: Record | null; + created_at: string | null; + actor: { id: number; name: string; initials: string } | null; +} + +export interface ApiDealDetail extends ApiDeal { + comment: string | null; + assigned_at: string | null; +} + +export interface GetDealResponse { + deal: ApiDealDetail; + events: ApiDealEvent[]; +} + +export async function getDeal(id: number, tenantId: number): Promise { + const { data } = await apiClient.get(`/api/deals/${id}`, { + params: { tenant_id: tenantId }, + }); + return data; +} + export interface ListDealsParams { tenantId: number; statusIn?: string[]; diff --git a/app/resources/js/components/deals/DealDetailDrawer.vue b/app/resources/js/components/deals/DealDetailDrawer.vue index 2174f3a0..a848f417 100644 --- a/app/resources/js/components/deals/DealDetailDrawer.vue +++ b/app/resources/js/components/deals/DealDetailDrawer.vue @@ -18,14 +18,17 @@ * - PATCH /api/deals/{id} — частичное обновление полей. * - GET /api/deals/{id}/events — `activity_log` фильтр по deal_id. */ -import { computed } from 'vue'; +import { computed, ref, watch } from 'vue'; import { LEAD_STATUSES } from '../../composables/leadStatuses'; import type { MockDeal } from '../../composables/mockDeals'; -import { MOCK_EVENTS, eventTypeIcon, eventTypeLabel } from '../../composables/mockDealEvents'; +import { type DealEvent, MOCK_EVENTS, eventTypeIcon, eventTypeLabel } from '../../composables/mockDealEvents'; +import { mapApiDealEvent } from '../../composables/dealsApiMapper'; +import * as dealsApi from '../../api/deals'; const props = defineProps<{ open: boolean; deal: MockDeal | null; + tenantId?: number; }>(); const emit = defineEmits<{ 'update:open': [value: boolean] }>(); @@ -49,6 +52,41 @@ function formatRelative(minutes: number): string { if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`; return `${Math.floor(minutes / (60 * 24))} д назад`; } + +// Activity timeline: при наличии tenant_id делаем GET /api/deals/{id} и +// показываем реальные events. На fail / без tenant_id — fallback на MOCK_EVENTS. +const events = ref([...MOCK_EVENTS]); +const eventsLoading = ref(false); +const eventsFetchError = ref(false); + +async function loadEvents() { + if (!props.deal || !props.tenantId) { + events.value = [...MOCK_EVENTS]; + return; + } + eventsLoading.value = true; + eventsFetchError.value = false; + try { + const res = await dealsApi.getDeal(props.deal.id, props.tenantId); + events.value = res.events.map((e) => mapApiDealEvent(e)); + } catch { + eventsFetchError.value = true; + events.value = [...MOCK_EVENTS]; + } finally { + eventsLoading.value = false; + } +} + +// Fetch при открытии drawer'а или смене сделки. +watch( + () => [props.open, props.deal?.id, props.tenantId] as const, + ([open]) => { + if (open) loadEvents(); + }, + { immediate: true }, +); + +defineExpose({ events, eventsLoading, eventsFetchError, loadEvents });