Commit Graph

106 Commits

Author SHA1 Message Date
Дмитрий 688d9cfb24 feat(redesign): Task 1 — tokens.css (12 colors + spacing + radii + shadows) 2026-05-12 09:15:29 +03:00
Дмитрий 76b1562593 feat(frontend): Plan 5 Task 11 — polling integration (setTimeout-recursion + backoff) 2026-05-11 19:44:56 +03:00
Дмитрий 1c3989a6df feat(frontend): Plan 5 Task 10 — EditProjectDialog wrapper + BulkActionsBar + 7 tests 2026-05-11 19:41:53 +03:00
Дмитрий 92082606e3 feat(frontend): Plan 5 Task 8 — ProjectsView + projectsStore (no polling) + 9 tests 2026-05-11 19:38:59 +03:00
Дмитрий 8bc7838f0c feat(frontend): Plan 5 Task 9 — NewProjectDialog (3 tabs Site/Call/SMS) + story 2026-05-11 19:31:26 +03:00
Дмитрий c9ee8d866e feat(frontend): Plan 5 Task 7 — router + nav + regions + ProjectCard + story 2026-05-11 19:31:23 +03:00
Дмитрий 174dbae808 feat(billing): Plan 4 Task 11 — TenantChargesController + ChargesTab + CSV export
Backend TenantChargesController:
- GET /api/billing/charges — paginated list, filters period (current_month / last_month / 90d) + charge_source.
- POST /api/billing/charges/export — StreamedResponse CSV (BOM + UTF-8) с chunkById(500).
- auth:sanctum + tenant middleware — RLS изолирует tenant_id.
- 6 Pest integration tests (RLS isolation + filters + pagination + CSV export).

Frontend ChargesTab.vue:
- v-data-table-server с paginated load + period/charge_source filters.
- CSV-download через blob → createObjectURL.
- Forest-palette + JetBrains Mono tnum.

BillingView.vue — добавлен tab «Списания» с импортом ChargesTab.
ChargesTab.story.vue + 4 Vitest tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:51:13 +03:00
Дмитрий 0f820c4569 feat(admin): Plan 4 Task 10 — AdminSuppliersController + AdminSupplierPricesView (B1/B2/B3 cost editor)
Backend AdminSuppliersController:
- GET /api/admin/suppliers — все 3 поставщика (B1/B2/B3).
- PATCH /api/admin/suppliers/{id} — обновляет cost_rub / quality_score / is_active.
- Validation: cost_rub >= 0, quality_score 0..9.99.
- Audit trail saas_admin_audit_log (stub admin via system-supplier@liderra.local).
- 4 Pest integration tests.

Frontend AdminSupplierPricesView (Vue 3 + Vuetify 3):
- v-data-table 3 строки с inline-editing cost_rub/quality_score/is_active.
- Forest-palette + JetBrains Mono tnum.
- 3 Vitest tests + Histoire story.

Router /admin/supplier-prices route.

Drive-by fix: SupplierProjectFactory.definition() default signal_type
ограничен ['site','call'] — иначе при ->create(['platform' => 'B1']) с
оригинальным random 'sms' нарушается CHECK chk_supplier_projects_b1_not_for_sms
(flaky parallel-pest race condition). Тесты, которым нужен 'sms', продолжают
явно передавать signal_type вместе с B2/B3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:28:03 +03:00
Дмитрий ed5e3f495d feat(admin): Plan 4 Task 9 — AdminPricingTiersController + AdminPricingTiersView (CRUD 7-tier + audit)
Backend AdminPricingTiersController:
- GET /api/admin/pricing-tiers — active + scheduled.
- POST — create 7-tier set с effective_from=DATE_TRUNC('month', NOW()+1 month).
- DELETE /scheduled/{date} — отмена будущей сетки.
- Validation: ровно 7 tier_no 1..7 unique, tier 7 leads_in_tier=null, price>=0.
- Audit trail saas_admin_audit_log на POST + DELETE (через SaasAdminAuditLog
  model: payload_before/after, NOT NULL admin_user_id резолвится через стаб
  system-pricing@liderra.local + ip_address из $request->ip()).
- 8 Pest integration tests.

Frontend AdminPricingTiersView (Vue 3 + Vuetify 3):
- v-data-table активной сетки + scheduled groups + dialog editor.
- Forest-palette + JetBrains Mono для tnum-цифр.
- 5 Vitest unit tests (tests/Frontend/, авто-импорт Vuetify через vite-plugin).
- Histoire story для preview.

Router /admin/pricing-tiers route (layout 'admin', requiresAuth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:18:01 +03:00
Дмитрий cab1f87efd phase2(admin-tenant-detail-frontend): replace mock на real API в AdminTenantDetailView
- api/admin.ts +getAdminTenantDetail(subdomain) + 5 типов (ApiTenantUser/Project/
  BalanceTx/ActivityEvent/Metrics + AdminTenantDetailResponse).
- composables/adminTenantDetailMapper.ts: mapAdminTenantDetail (API → mockTenantDetail
  format). code=subdomain, deriveStatus (trial/overdue/suspended), deriveTariff
  (Trial fallback), users (fullName из first+last||email, role='manager' хардкод —
  schema users role нет, расширим в Post-MVP), projects (slug=tag), balanceHistory
  (id префикс TX-, type-mapping для chargeback_*/trial_bonus/historical_import →
  ближайший UI-эквивалент), activity (actor=actor_email||system, summary из
  context.from→to), activitySinceText (relative time из last_activity_at).
- AdminTenantDetailView.vue: replace mock-lookup на async loadTenant + 3 ветки
  template (loading / notFound / fetchError) + watch(code) для реактивной
  навигации. inn/contact_phone/legal_address скрываются через v-if (нет в schema).
- AdminTenantDetailView.spec.ts переписан с MOCK на vi.mock('api/admin'):
  13 тестов (вызов API с subdomain / organization_name+tariff / 4 KPI / KPI Лиды
  todayActual/desired / Финансы tab / Пользователи tab / Проекты tab / Активность
  tab с actor+summary / Войти как клиент / suspended disabled / 404 fallback /
  500 fetch-error / overdue Просрочка / trial без оплаты).
- adminTenantDetailMapper.spec.ts +20 тестов: code/name/inn-empty/balanceRub
  parse/mrrRub trial-null/status (4 ветки)/tariff (deriveTariff+fallback)/today
  Actual+Desired/users (fullName / fallback)/projects/balanceHistory (TX- prefix +
  chargeback type mapping)/activity (actor+summary)/metrics (4 поля)/activitySince.
- Vitest +23 (всего 416/416, +23 от 393).

Этап B эпика AdminTenantDetailView (frontend) ЗАКРЫТ. Эпик закрыт целиком (2 этапа).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:37:45 +03:00
Дмитрий e0ffe7e686 phase2(reports-stage4): frontend integration ReportsView (replace mock)
- api/reports.ts: типизированные axios-helpers (listReportJobs/createReportJob/
  retryReportJob/cancelReportJob/deleteReportJob) + ApiReportJob/Status/Format/
  Counts/Quota interfaces. ensureCsrfCookie на mutating-вызовах.
- composables/reportsMapper.ts: mapApiReportJob (API → UI mock format с конверсией
  pending→queued / processing→running). title строится на frontend'е (тип + period
  с RU-месяцами «апр 2026» или диапазон «мар 2026 — апр 2026»). sizeText форматирует
  bytes (B/KB/MB). timeText зависит от status (в очереди / в работе · Nс / N мин назад).
  uiTypeToApi (deals → deals_export и т.д.).
- ReportsView.vue полностью переписан под API:
  - onMounted → loadJobs (replace MOCK_JOBS на data из listReportJobs).
  - usePolling 30 сек (фоновый авто-refresh).
  - Submit → createReportJob → reload + success-alert + error-alert (validation+
    общие ошибки извлекаются через extractValidationErrors/extractErrorMessage).
  - canSubmit computed: disable если квота заполнена (active >= max).
  - Reset-btn возвращает форму к defaults.
  - Reload-btn (manual fast-path).
  - Retry/Cancel/Download/Delete-кнопки → соответствующие API-вызовы;
    Delete через v-dialog persistent confirm.
  - fetch-error-alert на listReportJobs reject.
  - Empty-state «Нет отчётов» когда jobs.length=0.
  - canRetry проверяет retry_count<3 (max attempts CTO-6).
- Vitest +24 (всего 393/393, +24 от 369):
  - reportsMapper.spec.ts +14: status mapping (pending/processing/done/failed) /
    title (один месяц / диапазон) / format / sizeText (B/KB/MB/null) / attempt /
    error / timeText (pending / processing / done «10 мин назад» / «только что») /
    uiTypeToApi 4 slug'а / progress=50 для running.
  - ReportsView.spec.ts переписан с MOCK_JOBS на vi.mock('api/reports') +12:
    mount + listReportJobs called on mount / 4 type cards / default Сделки active /
    4 формата / quota-banner из API / empty-state / done с Готов+Скачать /
    failed с Ошибка+Повторить / failed retry_count=3 НЕ показывает Повторить /
    pending с Отменить / Submit вызывает createReportJob+reload / Submit error →
    submit-error-alert / Submit-btn disabled при квоте 3/3 / Reset / Reload-btn /
    fetch-error-alert / Retry-btn / Cancel-btn / Delete confirm-dialog +
    deleteReportJob.

Этап 4/4 эпика Reports backend ЗАКРЫТ. Эпик закрыт целиком.

Backend: 1 type (deals_export) × 4 формата (CSV/XLSX/JSON/PDF-stub).
Этап 2b (3 оставшихся типа: managers_summary/sources_summary/billing_summary)
— расширение через добавление 3 новых Provider-классов без изменений в архитектуре,
вынесено в Post-MVP backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:49:55 +03:00
Дмитрий dc1457a008 phase2(reminders-frontend): RemindersView + DealDetailDrawer + nav-badge
P0 этап 5 — frontend для reminders (после backend-этапа 4).
Пользователь может создавать/просматривать/завершать/удалять напоминания
из UI с inline-create в DealDetailDrawer.

Frontend:
- api/reminders.ts: типизированные helpers для 5 endpoints + ensureCsrfCookie
  для mutating. Types ReminderFilter/ApiReminder/ReminderCounts.
- stores/reminders.ts: Pinia с items/counts/currentFilter +
  load/refreshCounts/create/update/complete/remove. Optimistic для
  complete/remove с revert на reject.
- components/reminders/ReminderDialog.vue: dual-mode (create/edit) modal
  с native datetime-local input. Props dealId?/reminder? (edit),
  ISO-конверсия при submit.
- views/RemindersView.vue: page-head с stats (active/overdue) + reload-btn;
  4 tabs (today/upcoming/overdue/completed) с counts на бейджах
  (overdue=error color); v-list с complete-btn / dropdown
  Изменить/Удалить с confirm-dialog; empty-state.
- router: /reminders маршрут (lazy).
- AppLayout: nav-badge «Напоминания» биндит count из store
  (replace static «12»); скрыт при count=0; polling 60 сек для
  refreshCounts.
- DealDetailDrawer: секция «Напоминания» (только при tenantId+deal):
  inline + create-btn / список / complete / встроенный ReminderDialog.

Vitest +20 (369/369 за 21.20 сек):
- reminders-store 11: initial / load+reject / refreshCounts /
  create+reject / complete optimistic+revert / remove+reject / reset.
- RemindersView 7: mount / 4 tabs / counts / empty-state /
  список / reload-btn / filter=today default.
- AppLayout +2: бейдж скрыт при count=0 / показывает count при >0.

Pest 347/347 (без изменений — backend нетронут).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:41:41 +03:00
Дмитрий f55b91cfa4 phase2(notifications-stage3): NotificationsTab schema-aligned + prefs API
Закрывает архитектурное расхождение v1.28 — Tab сохранял prefs только
локально без API. Backend events не совпадали с handoff'ом.

Backend:
- PATCH /api/auth/me/notification-preferences под auth:sanctum.
- Replace-семантика: незадекларированные events/channels отбрасываются.
- userResource расширен: notification_preferences + sound_enabled.
- UserFactory с schema-default JSON (Eloquent не перечитывает после INSERT,
  DB-DEFAULT JSONB виден как null без явного override).
- Pest +10: 401 / replace / неизвестные events/channels отбрасываются /
  422 без prefs / sound_enabled опционален / bool-cast 1/'1' / replace-
  семантика (отсутствующие events исчезают).

Frontend:
- api/auth.ts: типы NotificationChannel/EventKey/Preferences +
  updateNotificationPreferences helper. AuthUser получил optional поля.
- NotificationsTab.vue переписан под schema:
  8 событий (new_lead/reminder/low_balance/zero_balance/topup_success/
  invoice_paid/new_device_login/marketing) × 3 канала (inapp/push/email,
  НЕ sms). Sync-init prefs (без onMounted — иначе v-if блокирует рендер
  и тесты mount-then-find падают). dirty через computed-сравнение с
  originalPrefs snapshot. save async + success/error alerts.
- SettingsView.spec.ts: legacy event-имена → schema-aligned.
- Vitest +10: 8 schema events / 3 channels (НЕ sms) / legacy отсутствуют /
  читает prefs из user / save calls API + alerts / Отменить возвращает.

cspell-words: +prefs.
PHPStan baseline регенерирован.

Pest 315/315 (+10) за 36.73 сек, 1130 assertions.
Vitest 349/349 (+10) за 20.42 сек.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:41:35 +03:00
Дмитрий 508de4eaf3 phase2(notifications-stage2b): API + Pinia + bell в AppLayout (P0 этап 2b)
Закрывает этап 2 P0 целиком (UI bell с unread badge + polling).

Backend:
- App\Http\Controllers\Api\InAppNotificationController под auth:sanctum:
  GET /api/notifications?unread_only=&limit= (1..100 default 50);
  PATCH /api/notifications/{id}/read (idempotent);
  POST /api/notifications/mark-all-read (bulk + count);
  DELETE /api/notifications/{id}.
- Route::middleware('auth:sanctum')->prefix('/api/notifications') в web.php.
- DB::transaction + SET LOCAL app.current_tenant_id для RLS.
- Защита от кражи чужого id через where('user_id', $auth->id).
- Pest +14 (305/305 за 34.71 сек, 1099 assertions).

Frontend:
- api/notifications.ts — типизированные axios-helpers + ensureCsrfCookie.
- stores/notifications.ts — Pinia: items/unreadCount/total/loading +
  optimistic markRead/markAllRead/remove с revert на reject.
- AppLayout: bell-icon → v-menu offset=8 location=bottom-end:
  pip badge показывает unreadDisplay (1..99 / 99+ / hidden);
  v-list последних 10 из sortedItems с event-icon + formatRelative;
  Mark-all-read btn только при unreadCount > 0;
  click на item → markRead + router.push('/deals') если deal_id.
- usePolling(loadNotifications, {intervalMs: 30_000}) с Page Visibility.
- loadNotifications no-op без auth.user.
- Vitest +18 (339/339 за 20.03 сек): store 12 + AppLayout +6
  (bell-btn / pip скрыт при 0 / pip count / 99+ / listNotifications
  на mount с user / no-op без user).

PHPStan baseline регенерирован (50 Pest false-positives подавлены).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:27:57 +03:00
Дмитрий 830a652588 phase2(trash-bin): GET /api/deals?only_deleted + «Корзина» в DealsView
Расширяет stages 5/6 (soft-delete + 8-сек undo) до постоянного доступа
к удалённым сделкам через отдельный view-mode.

Backend (DealController::index):
- Новый query-param only_deleted=true.
- withTrashed() + whereNotNull('deleted_at') — обход global scope
  SoftDeletes + явный фильтр для NO-OP idempotency.
- Все остальные фильтры применимы и в trash-mode.

Pest +3 (DealIndexTest):
- only_deleted=true → только soft-deleted (alive скрыты).
- Без only_deleted → soft-deleted скрыты (default behavior).
- RLS+app-фильтр изолирует чужие удалённые.

Frontend:
- ListDealsParams.onlyDeleted?: boolean + axios mapping.
- DealsView: trashMode ref + toggleTrashMode (clear selected + reload) +
  applyBulkRestoreFromTrash (optimistic remove + bulkRestoreDeals + toast).
- UI changes в trash-mode:
  - Заголовок «Сделки» → «Корзина».
  - Toggle-btn 'mdi-arrow-left К сделкам' (warning-flat) вместо
    'mdi-trash-can-outline Корзина' (outlined).
  - Скрыты Экспорт + Новая сделка.
  - Скрыт chiprow filter-bar.
  - Info-alert «Корзина: показаны удалённые сделки».
  - Bulk-bar: только Восстановить (mdi-restore success-tonal) + clear;
    status/export/delete скрыты.

Vitest +2 (DealsListIntegration):
- toggleTrashMode → trashMode=true + listDeals с onlyDeleted=true.
- applyBulkRestoreFromTrash → bulkRestoreDeals + remove from state +
  toast «Восстановлено 2».

PHPStan baseline регенерирован.

Регресс:
- Lint+type-check+format passed.
- Vitest 321/321 за 19.60 сек (+2 от 319).
- Vite build 1.04 сек.
- Pint + PHPStan passed.
- Pest 269/269 за 29.12 сек (+3 от 266, 1009 assertions).

Реестр v1.72→v1.73 / CLAUDE.md v1.63→v1.64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:27:11 +03:00
Дмитрий 01c20e7b6c phase2(polling): usePolling composable 30 сек + Page Visibility pause
Закрывает последний unblocked production-TODO «Polling/SSE для real-time».
Manual reload-btn остаётся как fast-path; polling — фоновый автообновитель.

Composable (composables/usePolling.ts):
- usePolling(loader, {intervalMs=30_000, enabled=true}).
- Page Visibility API: при document.hidden=true interval останавливается;
  при visibilitychange с возвратом hidden=false — restart + немедленный
  loader() (не ждать следующего interval'а).
- Cleanup на onBeforeUnmount: clearInterval + removeEventListener.
- enabled=false — composable не стартует (feature-flag).

Integration:
- DealsView + KanbanView → loadDeals.
- AdminTenantsView → loadTenants.
- AdminBillingView → loadBilling.
- AdminIncidentsView → loadIncidents.

Vitest +6 (usePolling.spec.ts) с vi.useFakeTimers:
- Вызов каждые intervalMs / default 30 сек / skip при document.hidden /
  cleanup на unmount / enabled=false → no-op / visibilitychange
  pause+resume с немедленным loader.

Регресс:
- Lint+type-check+format passed.
- Vitest 319/319 за 18.67 сек (+6 от 313).
- Vite build 899 ms.
- Pint + PHPStan passed.
- Pest 266/266 за 28.62 сек (backend не тронут).

Реестр v1.71→v1.72 / CLAUDE.md v1.62→v1.63.
ВСЕ unblocked production-TODO закрыты.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:17:51 +03:00
Дмитрий fa11c7b223 phase2(admin-tenants-mrr): mrr_rub в /api/admin/tenants (этап 7)
Закрывает gap из v1.66 — mock-форма имеет mrrRub, но API возвращал null.
Теперь AdminTenantsView показывает реальную колонку MRR.

Backend (AdminTenantsController::index):
- Добавлено tariff_plans.price_monthly as tariff_price_monthly в select.
- mrr_rub в response: price_monthly (string) если не-trial; иначе null.
- Aggregate-формат как у /admin/billing — string чтобы decimal не терял
  точность при передаче через JSON.

Pest +3 (AdminTenantsIndexTest):
- mrr_rub='990.00' для активного тарифа не-trial.
- mrr_rub=null для trial (даже если тариф есть).
- mrr_rub=null если current_tariff_id отсутствует.

Frontend:
- ApiAdminTenant.mrr_rub: string | null в типе.
- mapApiAdminTenant: parseFloat(api.mrr_rub) или null (вместо hardcoded
  null из v1.66).
- AdminTenantsView: formatRub(item.mrrRub) для консистентности с другими
  ₽-полями.

Vitest +2:
- mrr_rub строка → number.
- mrr_rub=null → mrrRub null.

PHPStan baseline регенерирован. cspell-glossary +консистентности.

Регресс:
- Lint+type-check+format passed.
- Vitest 313/313 за 18.83 сек (+2 от 311).
- Vite build 947 ms.
- Pint + PHPStan passed.
- Pest 266/266 за 28.39 сек (+3 от 263, 1001 assertion).

Реестр v1.70→v1.71 / CLAUDE.md v1.61→v1.62.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:08:12 +03:00
Дмитрий c34d4009d1 phase2(restore-flow): POST /api/deals/restore + undo-snackbar (этап 6 — completion of stage 5)
Soft-delete был half-done: пользователь не мог отменить случайное удаление.
Теперь после bulk-delete показывается snackbar «Удалено N · Восстановить»
на 8 секунд.

Backend (DealController::restore):
- POST /api/deals/restore {tenant_id, ids: [1..1000 ints]}.
- withTrashed() обходит global scope SoftDeletes + явный
  whereNotNull('deleted_at') для NO-OP idempotency на живых.
- RLS + defense-in-depth where(tenant_id).
- ActivityLog event=deal.restored, context.source='bulk' для каждой
  ВОССТАНОВЛЕННОЙ. Константа EVENT_DEAL_RESTORED добавлена в модель.

Pest +7 (DealRestoreTest):
- 422/404 базовые / soft-delete + restore + audit / NO-OP на живых
  не пишет audit / defense-in-depth (свой restored, чужой остался) /
  после restore видна в GET /api/deals / 422 пустой массив.

Frontend:
- dealsApi.bulkRestoreDeals — POST-helper.
- DealsView::applyBulkDelete: snapshot удалённых сделок (deep-clone
  manager.*) сохраняется в lastDeletedSnapshot ref.
- undoBulkDelete() async: optimistic re-insert + bulkRestoreDeals если
  auth.user; success → toast «Восстановлено N»; fail → warning.
- v-snackbar bulk-delete: 3→8 сек timeout + #actions слот с кнопкой
  «Восстановить» (показ только при snapshot.length > 0). После undo
  snapshot очищается → кнопка пропадает.

Vitest +3 (DealsListIntegration):
- bulk-delete + undo восстанавливает обе + bulkRestoreDeals + cleanup
  snapshot.
- Undo без tenant_id — НЕ вызывает API + только локально.
- Undo reject → warning toast + локальное восстановление остаётся.

PHPStan baseline регенерирован. cspell-glossary +unshift +партиальный.

Регресс:
- Lint+type-check+format passed.
- Vitest 311/311 за 18.71 сек (+3 от 308).
- Vite build 877 ms.
- Pint + PHPStan passed.
- Pest 263/263 за 27.68 сек (+7 от 256, 998 assertions).

Реестр v1.69→v1.70 / CLAUDE.md v1.60→v1.61.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:01:35 +03:00
Дмитрий f0dce283a8 phase2(soft-delete): schema v8.9 + DELETE /api/deals (этап 5/5 — авто-план закрыт)
Bulk soft-delete для UI applyBulkDelete. Hard-delete отбракован из-за
CASCADE-FK от webhook_dedup_keys: hard уничтожил бы dedup-ключи и
нарушил идемпотентность webhook §5.5.

Schema v8.8 → v8.9:
- deals.deleted_at TIMESTAMPTZ (NULL = живая).
- Partial index (tenant_id, status) WHERE deleted_at IS NULL —
  самый частый UI-фильтр.
- ALTER TABLE на партиционированной deals distributes во все 6
  партиций автоматически (PG 14+).
- CHANGELOG +§U с обоснованием soft vs hard.

Backend (DealController::destroy):
- DELETE /api/deals {tenant_id, ids: [1..1000 ints]}.
- Bulk-update deleted_at=NOW() через RLS + defense-in-depth where(tenant_id).
- ActivityLog event=deal.deleted (source='bulk') для каждой ИЗМЕНЁННОЙ.
- NO-OP (уже удалена) не пишет audit.
- Deal model: SoftDeletes trait + deleted_at в fillable/casts. Global
  scope автоматически добавляет whereNull('deleted_at') ко всем существующим
  query (index/show/transition/update/export).

Pest +8 (DealDestroyTest):
- 422/404 базовые / soft-delete + audit / defense-in-depth (свой
  удалён, чужой жив) / NO-OP idempotency / GET скрывает soft-deleted
  (list+show 404) / 422 пустой массив.
- Quirk: migrate:fresh --env=testing без .env.testing использует liderra
  вместо liderra_testing → решение DB_DATABASE=liderra_testing migrate:fresh.

Frontend:
- dealsApi.bulkDeleteDeals — DELETE-helper с config.data (axios особенность).
- DealsView::applyBulkDelete async: optimistic local-removal +
  bulkDeleteDeals если auth.user; success → toast «Удалено N из M.»;
  fail → warning toast + локальный update НЕ откатывается.

Vitest +3 (DealsListIntegration):
- bulkDeleteDeals с tenant_id + optimistic + toast.
- Без tenant_id — НЕ вызывается.
- Reject → warning toast + локальный update остаётся.

PHPStan baseline регенерирован.

АВТО-ПЛАН (5 этапов) ЗАКРЫТ ПОЛНОСТЬЮ.

Регресс:
- Lint+type-check+format passed.
- Vitest 308/308 за 20.12 сек (+3 от 305).
- Vite build 973 ms.
- Pint + PHPStan passed.
- Pest 256/256 за 27.75 сек (+8 от 248, 977 assertions).

Реестр v1.68→v1.69 / CLAUDE.md v1.59→v1.60.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:51:47 +03:00
Дмитрий 14dc317e2b phase2(admin-incidents): GET /api/admin/incidents + AdminIncidentsView API (этап 4/5)
Чтение incidents_log с фильтрами type/severity/unresolved_only + summary
(open/investigating/rkn_pending/total_unresolved).

Backend (AdminIncidentsController::index):
- ORDER BY started_at DESC. Filters: type, severity, unresolved_only=true.
- Derived: incident_id (INC-YYYY-MMDD-NNNN), status (resolved_at!=null →
  resolved; detected_at!=null → investigating; иначе open),
  affected_tenants_count из BIGINT[] (parsePgArray для '{1,2,3}'),
  rkn_deadline_at = detected_at+24h для data_breach без notification.
- summary: open/investigating/rkn_pending/total_unresolved.

Pest +11 (AdminIncidentsIndexTest):
- пустой / incident_id формат / derive status / filter type+severity /
  unresolved_only / ORDER BY started_at DESC / rkn_deadline +24h для
  data_breach / non-data_breach без deadline / summary.rkn_pending /
  limit+offset.
- Quirk: saas_admin_users.full_name (не first/last) + нет updated_at.

Frontend:
- api/admin.ts::listAdminIncidents — типизированный helper.
- AdminIncidentsView: унифицированный IncidentRow (mock-category ↔
  API-type, mock-title ↔ API-summary). Reactive rowsState+stats default
  = MOCK; loadIncidents() async на onMounted; fetchError + warning
  alert + MOCK fallback; reload-btn. РКН pending chip учитывает оба
  pdn_breach/data_breach.

Vitest +5:
- listAdminIncidents на mount / replace state+stats + rkn_deadline /
  reject → fetchError+alert+fallback / reload-btn x2 / РКН pending chip
  виден для data_breach без notification.

PHPStan baseline регенерирован. cspell-glossary +MMDD.

Регресс:
- Lint+type-check+format passed.
- Vitest 305/305 за 20.59 сек (+5 от 300).
- Vite build 1.05 сек.
- Pint + PHPStan passed.
- Pest 248/248 за 28.02 сек (+11 от 237, 951 assertion).

Реестр v1.67→v1.68 / CLAUDE.md v1.58→v1.59.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:38:34 +03:00
Дмитрий 4532b95d64 phase2(admin-billing): GET /api/admin/billing + AdminBillingView API (этап 3/5)
Aggregates пополнений/списаний за текущий месяц по balance_transactions
+ summary с MRR/revenue/overdue/refunds_30d.

Backend (AdminBillingController::index):
- GET /api/admin/billing?search=. Per-tenant SUM с CASE WHEN type IN
  ('topup','lead_charge') GROUP BY tenant_id; ABS для charges.
- Row: id/subdomain/organization_name/contact_email/status/balance_rub/
  tariff_id/tariff_name/mrr_rub (=tariff.price_monthly если не-trial)/
  monthly_topups_rub/monthly_charges_rub/last_payment_at/
  chargeback_unrecovered_rub.
- summary: total_mrr_rub (SUM не-trial), monthly_revenue_rub (SUM topup),
  overdue_count (balance<0 || chargeback>0), refunds_count_30d.
- Quirk: schema-колонка tariff_plans.price_monthly (НЕ price_rub_monthly)
  — обнаружено первым прогоном Pest, исправлено сразу.

Pest +9 (AdminBillingIndexTest):
- пустой / поля+tariff JOIN / aggregates за месяц / прошлый месяц не
  попадает / overdue / refunds_30d (старые исключены) / total_mrr_rub
  (trial исключаются) / search ILIKE / soft-deleted скрыт.

Frontend:
- api/admin.ts::listAdminBilling — типизированный helper.
- AdminBillingView: reactive rowsState+summary default = MOCK,
  loadBilling() async на onMounted парсит API-строки → numbers + derive
  status (suspended/balance<0||chargeback>0→overdue/active). На fail —
  fetchError + warning alert + MOCK fallback. Reload-btn.
- tariffLabel/statusInfo обобщены с fallback'ами на новые slug'и.

Vitest +4:
- listAdminBilling на mount / replace rowsState+summary + string→number
  + status derive / reject → fetchError+alert+fallback / reload-btn x2.

PHPStan baseline регенерирован.

Регресс:
- Lint+type-check+format passed.
- Vitest 300/300 за 18.41 сек (+4 от 296).
- Vite build 925 ms.
- Pint + PHPStan passed.
- Pest 237/237 за 27.69 сек (+9 от 228, 926 assertions).

Реестр v1.66→v1.67 / CLAUDE.md v1.57→v1.58.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:28:49 +03:00
Дмитрий 6ef9961f5f phase2(admin-tenants): GET /api/admin/tenants + AdminTenantsView API (этап 2/5)
AdminTenantsView переходит с mock-данных на live backend.

Backend (AdminTenantsController::index):
- GET /api/admin/tenants?status=&search=&limit=&offset=.
- LEFT JOIN tariff_plans для tariff_name. ORDER BY last_activity_at DESC.
- ILIKE search по organization_name + subdomain + contact_email.
- stats {total, active, trial, overdue} — overdue считает balance<0
  ИЛИ chargeback_unrecovered_rub > 0.
- На MVP без auth (saas-admin SSO ⏸ Б-1).

Pest +8 (AdminTenantsIndexTest):
- 200 + пустой / все поля / status filter / search ILIKE /
  ORDER BY last_activity_at DESC / stats / soft-deleted скрыт /
  limit+offset.

Frontend:
- api/admin.ts::listAdminTenants — типизированный helper.
- composables/adminTenantsMapper.ts::mapApiAdminTenant — converter
  API → UI: status derive (is_trial→trial, chargeback>0||balance<0
  →overdue), inn='', code=subdomain, tariff clamp на known TenantTariff,
  todayActual/mrrRub отсутствуют в API → 0/null, activitySince через
  formatRelative.
- AdminTenantsView: reactive tenantsState+stats default = MOCK,
  loadTenants() на onMounted → splice replace; на fail — fetchError +
  warning alert + MOCK fallback. Reload-btn.

Vitest +13:
- View-integration (4): listAdminTenants на mount / replace state+stats /
  reject → fetchError + alert + fallback / reload-btn x2.
- Mapper (9): name/code/inn/status-derives (trial/overdue/suspended) /
  balance_rub→number / activitySince + null fallback.

PHPStan baseline регенерирован.

Регресс:
- Lint+type-check+format passed.
- Vitest 296/296 за 18.91 сек (+13 от 283).
- Vite build 1.02 сек.
- Pint + PHPStan passed.
- Pest 228/228 за 25.22 сек (+8 от 220, 906 assertions).

Реестр v1.65→v1.66 / CLAUDE.md v1.56→v1.57.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:19:53 +03:00
Дмитрий 7e1bf8b42d phase2(deal-patch): PATCH /api/deals/{id} + comment-editor в DealDetailDrawer (этап 1/5)
Drawer из read-only становится editable. ActivityLog event пишется на
каждое изменение поля.

Backend (DealController::update):
- PATCH /api/deals/{id} {tenant_id, comment?, manager_id?, status?}.
- Каждое изменённое поле → ActivityLog:
  comment → deal.commented (context.text);
  manager_id → deal.assigned (context.from/to + assigned_at=NOW);
  status → deal.status_changed (context.from/to/source='manual').
- NO-OP не пишется в audit. Manager FK guard + status slug validation.
- RLS + defense-in-depth where(tenant_id) → 404 для чужой сделки.

Pest +10 (DealUpdateTest):
- 422/404 базовые / 404 чужая сделка / comment+audit / manager+audit+
  assigned_at / status+audit / 422 неизвестный slug / 422 чужой manager /
  NO-OP не пишет / комбинированно → 2 audit записи.

Frontend:
- api/deals.ts::updateDeal — PATCH helper c ensureCsrfCookie.
- DealDetailDrawer: новая секция «Комментарий» (только при tenantId).
  v-textarea auto-grow + counter=5000 + Save-btn → updateDeal →
  toast success + reload events (новый deal.commented в timeline).
  На fail → warning toast.

Vitest +3 (DealDetailDrawerApi):
- saveComment вызывает updateDeal + toast + reload events (getDeal x2).
- saveComment reject → commentSaveError + warning toast.
- comment-section не рендерится без tenantId.

PHPStan baseline регенерирован.

Регресс:
- Lint+type-check+format passed.
- Vitest 283/283 за 18.13 сек (+3 от 280).
- Vite build 1.12 сек.
- Pint + PHPStan passed.
- Pest 220/220 за 25.64 сек (+10 от 210, 871 assertion).

Реестр v1.64→v1.65 / CLAUDE.md v1.55→v1.56.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:10:58 +03:00
Дмитрий e31ea5354a phase2(lead-statuses): GET /api/lead-statuses + Pinia-store с snapshot fallback
Заменяет static-снапшот LEAD_STATUSES в коде на live-данные из БД.
Custom slug'и (добавленные после deployment'а) теперь видны UI без rebuild'а.

Backend:
- LeadStatus model (PK=slug string, incrementing=false, timestamps=null).
- LeadStatusController::index — GET /api/lead-statuses, ORDER BY sort_order,
  slug. Таблица глобальная (не tenant-aware), auth не требуется на MVP.

Pest +5 (LeadStatusesIndexTest):
- 200 + не пустой / все 14 системных slug'ов из seed / все нужные поля /
  sort_order ASC / кастомный slug после INSERT появляется в endpoint'е.

Frontend:
- api/leadStatuses.ts::listLeadStatuses — GET helper.
- stores/leadStatuses.ts::useLeadStatusesStore — Pinia setup-store:
  statuses default = LEAD_STATUSES snapshot (UI работает без fetch'а),
  load(force=false) идемпотентен, bySlug computed Map, findBySlug helper.
  На fail — snapshot остаётся, fetchError=true.
- DealsView/KanbanView/DealDetailDrawer переехали со static-импорта
  LEAD_STATUSES на store. KanbanView использует safe-access
  dealsByStatus[slug] || [] (защита от custom slug'а из API без seeded
  column). load() в onMounted у обоих view'ов.

Vitest +7 (leadStatusesStore.spec.ts):
- initial snapshot / findBySlug existing & null / load success replace +
  loaded / load reject — fetchError + snapshot fallback / load идемпотентен /
  load(force=true) refetch.
- 2 spec'а DealDetailDrawer получили setActivePinia(createPinia()) в
  beforeEach (без этого Pinia store-injection в jsdom падает).

PHPStan baseline регенерирован.

Регресс:
- Lint+type-check+format passed.
- Vitest 280/280 за 19.44 сек (+7 от 273).
- Vite build 1.17 сек.
- Pint + PHPStan passed.
- Pest 210/210 за 24.59 сек (+5 от 205, 840 assertions).

Реестр v1.63→v1.64 / CLAUDE.md v1.54→v1.55.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:59:17 +03:00
Дмитрий cba76c5d18 phase2(deal-show): GET /api/deals/{id} + DealDetailDrawer на реальный ActivityLog
Закрывает gap «timeline в drawer'е показывает hard-coded MOCK_EVENTS» —
теперь drawer fetch'ит реальные activity-events на open из tenant-filtered
activity_log. Без tenant_id — fallback на MOCK_EVENTS как раньше.

Backend (DealController::show):
- GET /api/deals/{id}?tenant_id={id} — возвращает {deal, events}.
- Deal extended (project_name, manager_name/initials, comment, assigned_at).
- Events — последние 50 записей activity_log по (tenant_id, deal_id)
  ORDER BY created_at DESC, с актором (user через belongsTo).
- RLS-обёртка + defense-in-depth where(tenant_id) — 404 если чужая.

Pest +8 (DealShowTest):
- 422/404 базовые / 404 чужая сделка / deal-relations / events ORDER BY +
  actor + actor=null для system-event / RLS+app-фильтр изоляция событий /
  лимит 50 событий.

Frontend:
- api/deals.ts::getDeal — типизированный helper c ApiDealEvent/Detail/Response.
- composables/dealsApiMapper.ts::mapApiDealEvent — converter ApiDealEvent →
  DealEvent: clamp event-slug на known types с fallback на 'deal.viewed';
  detail зависит от type (status_changed: 'from → to'; created: source;
  остальные: JSON-сводка context).
- DealDetailDrawer: optional tenantId prop, watch([open, deal.id, tenantId])
  с immediate=true → loadEvents() на open. Reject → eventsFetchError +
  v-alert warning + MOCK_EVENTS fallback.
- DealsView/KanbanView передают :tenant-id="auth.user?.tenant_id".

Vitest +4 (DealDetailDrawerApi.spec.ts):
- Без tenantId — getDeal не вызывается + MOCK_EVENTS видны.
- С tenantId — getDeal + events замещены + 'new → paid' виден.
- reject → fetchError + alert + MOCK_EVENTS fallback.
- open=false → getDeal не вызывается.

PHPStan baseline регенерирован.

Регресс:
- Lint+type-check+format passed.
- Vitest 273/273 за 20.76 сек (+4 от 269).
- Vite build 1.12 сек.
- Pint + PHPStan passed.
- Pest 205/205 за 24.19 сек (+8 от 197, 812 assertions).

Реестр v1.62→v1.63 / CLAUDE.md v1.53→v1.54.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:21:50 +03:00
Дмитрий a1ea003642 phase2(xlsx-export): PhpSpreadsheet 5.7 + format=csv|xlsx на /api/deals/export
Закрыт TODO «реальный XLSX-export» из v1.51. Russian users prefer .xlsx
(1С/Excel) — заменяет CSV как default. CSV остаётся через format=csv.

Backend (DealController::export):
- Body теперь: {tenant_id, ids, format?: 'csv' | 'xlsx'}; default 'csv'.
- buildXlsx: Spreadsheet + setTitle 'Сделки' + setCellValue A1..G1
  headers + bold(A1:G1) + setAutoSize всех колонок A..G. Writer пишет
  через ob_start/php://output для возврата бинарной строки.
- Content-Type application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
  + Content-Disposition с .xlsx.

Quirk: PhpSpreadsheet 5.x удалил deprecated setCellValueByColumnAndRow —
мигрировал на A1-нотацию (setCellValue('A2', $val)).

Pest +4 (DealCreateTest):
- xlsx binary с Content-Type + magic bytes "PK\x03\x04" + size >2KB.
- IOFactory::createReader('Xlsx') распаковывает: sheet «Сделки» +
  A1='ID' bold + A2/B2/C2 — реальные данные сделки.
- 422 на неизвестный format.
- Default (без format) — backward-compat CSV.

Frontend:
- api/deals.ts разделён: exportDeals (CSV string) + exportDealsXlsx
  (Blob, responseType='blob').
- applyBulkExport(format='xlsx' | 'csv') в DealsView — default 'xlsx'.
  XLSX → triggerBlobDownload (новый helper). CSV → старый CSV-helper.
  На fail — fallback на local CSV.

Vitest +3 (DealsListIntegration):
- xlsx default → exportDealsXlsx + Blob download + toast «XLSX».
- 'csv' → exportDeals + toast «CSV».
- xlsx reject → fallback на local CSV + toast «Backend недоступен».

PHPStan baseline регенерирован (удалена unmatched ignore-запись для
setCellValueByColumnAndRow). cspell-glossary +дефолтит +vnd +spreadsheetml.

Регресс:
- Lint+type-check+format passed.
- Vitest 269/269 за 18.49 сек (+3 от 266).
- Vite build 982 ms.
- Pint + PHPStan passed.
- Pest 197/197 за 26.05 сек (+4 от 193, 784 assertions).

Реестр v1.61→v1.62 / CLAUDE.md v1.52→v1.53.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:08:53 +03:00
Дмитрий ac186593f2 phase2(bulk-transition+reload): POST /api/deals/transition + reload-btn
Закрывает gap «UI меняет статус, но изменения не сохраняются на backend»
из v1.51. Reload-btn заменяет polling/SSE до прихода long-poll'а на prod.

Backend (DealController::transition):
- POST /api/deals/transition {tenant_id, ids: [1..1000 ints], status}.
- Валидация status — exists в lead_statuses (глобальная таблица).
- RLS-обёртка SET LOCAL + defense-in-depth where(tenant_id) для
  partial-update: чужие id остаются в исходном статусе.
- ActivityLog event=deal.status_changed с context={from, to, source: 'bulk'}
  для каждой ИЗМЕНЁННОЙ сделки. NO-OP (старый==новый) не пишется в audit.
- Ответ: {updated, requested, status}.

Pest +7 (DealTransitionTest):
- 422 missing fields / 404 unknown tenant / 422 неизвестный slug + не апдейт /
  batch update 3 сделок + 3 ActivityLog с правильным context /
  NO-OP не пишет ActivityLog / defense-in-depth (2 tenant'а — обновляется
  только свой) / 422 пустой массив ids.

Frontend:
- dealsApi.transitionDeals — типизированный helper с ensureCsrfCookie.
- applyBulkStatus в DealsView переписан async: optimistic local-update +
  backend-вызов если auth.user.tenant_id. На success — toast «Обновлено
  N из M.», на fail — warning toast + локальный update НЕ откатывается.
  Без auth.user — только optimistic (legacy local-mode сохранён).
- reload-btn в DealsView и KanbanView — outlined «Обновить» mdi-refresh,
  привязан к loadDeals. В DealsView :loading="loading" во время fetch'а.

Vitest +5:
- reload-btn (Deals + Kanban) — listDeals вызывается дважды.
- applyBulkStatus с tenant_id — transitionDeals + optimistic + toast.
- applyBulkStatus без tenant_id — НЕ вызывается transitionDeals.
- applyBulkStatus reject — toast warning + локальный update остаётся.

PHPStan baseline регенерирован. cspell-glossary +апдейт*.

Регресс:
- Lint+type-check+format passed.
- Vitest 266/266 за 18.16 сек (+5 от 261).
- Vite build 1.06 сек.
- Pint + PHPStan passed.
- Pest 193/193 за 23.27 сек (+7 от 186, 767 assertions).

Реестр v1.60→v1.61 / CLAUDE.md v1.51→v1.52.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 07:46:19 +03:00
Дмитрий 339e8ea53b phase2(deals-list-api): GET /api/deals + замена MOCK_DEALS на fetch с fallback
Закрыт TODO (c) из v1.50: backend-эндпоинт для списка сделок и его
интеграция в DealsView/KanbanView вместо статичного MOCK_DEALS.

Backend (DealController::index):
- Query-params: tenant_id (required, 422/404), status_in[] (whereIn),
  project_id, manager_id, search (ILIKE по phone+contact_name),
  limit clamp [1..500] default 100, offset default 0.
- ORDER BY received_at DESC, id DESC. Eager-load project + manager.
- RLS-обёртка SET LOCAL app.current_tenant_id + defense-in-depth
  where(tenant_id) — на тестах через postgres superuser RLS обходится
  BYPASSRLS, app-фильтр гарантирует изоляцию.
- Ответ: {deals: [...], total, limit, offset}; manager_name/initials
  форматируются через ManagerController::formatName/formatInitials.

Pest +12 (DealIndexTest):
- 422/404, пустой список, relations (project_name+manager_name+initials),
  RLS-изоляция, ORDER BY, status_in[], project_id, manager_id, search
  ILIKE, limit+offset, manager=null edge case.

Frontend:
- api/deals.ts::listDeals — типизированный helper c ApiDeal/ListDeals*.
- composables/dealsApiMapper.ts::mapApiDeal — converter ApiDeal→MockDeal:
  contact_name fallback на phone, manager.name='Не назначен' /
  initials='—' при null, project='—' при null, cost=0,
  receivedMinutesAgo=max(0, …) от clock-skew.
- DealsView/KanbanView: onMounted(loadDeals) async-вызывает listDeals
  если auth.user.tenant_id, на success replace через splice, на fail
  fetchError=true + v-alert warning, MOCK_DEALS как fallback.

Vitest +14:
- dealsApiMapper.spec.ts (8): 1:1, fallback'и, edge cases.
- DealsListIntegration.spec.ts (6): без tenant_id — НЕ вызывает API,
  с tenant_id — replace state, reject → fetchError + alert + fallback;
  для DealsView и KanbanView.

PHPStan baseline регенерирован. cspell-glossary +ILIKE +DTO.

Регресс:
- Lint+type-check+format passed.
- Vitest 261/261 за 19.62 сек (+14 от 247).
- Vite build 989 ms.
- Pint + PHPStan passed.
- Pest 186/186 за 22 сек (+12 от 174, 742 assertions).

Реестр v1.59→v1.60 / CLAUDE.md v1.50→v1.51.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 07:34:39 +03:00
Дмитрий 515114cff5 phase2(lookups+integrity): GET /api/managers+projects + manager FK guard + SupplierLeadCost для manual
3 интеграционных доработки после backend-completion v1.57.

(1) GET /api/managers + /api/projects + manager FK guard:
- ManagerController::index — active users тенанта (is_active+deleted_at IS NULL).
  Формат {id, email, first_name, last_name, name, initials} с
  formatName/formatInitials helpers (fallback на email).
- ProjectController::index — active projects (is_active=true).
- Оба endpoint'а: tenant_id query-param, 422 без, 404 unknown, RLS-обёртка.
- DealController::store FK guard: manager_id должен принадлежать tenant'у +
  is_active. Иначе 422 (закрывает security-gap чужого менеджера).
- Pest +8 в LookupsTest.

(2) Replace MOCK_MANAGERS / MOCK_PROJECTS на API в NewDealDialog:
- projectOptions/managerOptions ref'ы с MOCK fallback.
- loadLookups через Promise.all([listProjects, listManagers]) на open
  диалога с tenantId.
- managerIdByName Map name→id для submit'а.
- Silent fallback на mock при network-error.
- Vitest +2.

(3) SupplierLeadCost для manual-leads:
- В DealController::store после Deal::create — resolveSupplierId (копия
  логики ProcessWebhookJob: project_suppliers JOIN suppliers + ORDER BY
  sort_order). Если supplier найден — SupplierLeadCost с snapshot cost_rub
  + supplier_lead_id=NULL (manual: нет внешнего id).
- Manual по-прежнему НЕ списывает баланс (Ю-2 reseller-модель — charge
  только при webhook'е); cost-аналитика всё равно нужна.
- Pest +2.
- TODO: рефактор resolveSupplierId в App\Services\SupplierResolver чтобы
  Job + Controller разделяли логику.

Старый тест manager_id=42 переписан под FK guard через User::factory.

PHPStan baseline регенерирован (+28 ignored Pest TestCall warnings).

Регресс: lint+type-check+format ; vitest 247/247 за 16.32 сек (+2);
vite build 951 ms; Pint+PHPStan passed; Pest 166/166 за 22.11 сек
(+10 от 156, 699 assertions). Реестр v1.57→v1.58, CLAUDE.md v1.48→v1.49.

Production TODO остаточные:
- resolveSupplierId → SupplierResolver service.
- XLSX-export через PhpSpreadsheet.
- GET /api/deals для replace MOCK_DEALS в DealsView/KanbanView.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:58:49 +03:00
Дмитрий 83bb9de2bb phase2(backend-completion): POST /api/deals + webhook_hmac_required + POST /api/deals/export
3 backend-completion после tightening v1.56.

(1) POST /api/deals — manual create endpoint:
- DealController::store. Project firstOrCreate (type='manual'). Deal с
  source_crm_id=NULL. RLS-обёрнутая транзакция.
- Manual НЕ списывает баланс / НЕ дедуп / НЕ SupplierLeadCost.
  ActivityLog с context.source=manual.
- NewDealDialog получил optional tenantId prop. С tenantId — POST → backend-id;
  на error fallback на local-id + warning + dialog open.
- DealsView/KanbanView передают auth.user?.tenant_id.
- Pest +8.

(2) webhook_hmac_required flag в system_settings:
- Seed-row в db/schema.sql (default false backward-compat).
- WebhookReceiveController::isHmacRequired private helper.
- При true: запрос без X-Webhook-Signature → 401.
- Pest +3.

(3) POST /api/deals/export — backend CSV:
- DealController::export. Валидация ids[1-10000]. RLS-обёрнутый whereIn.
- Excel-friendly CSV: BOM "\u{FEFF}" PHP-литерал, ; разделитель, \r\n.
- text/csv attachment headers.
- Frontend applyBulkExport: backend → fallback на client-side
  (buildLocalCsv вынесен).
- Pest +4.

Vitest +3 (всего 245/245).
PHPStan убрал лишнюю Deal->id===null проверку (Eloquent int).
DealsView/KanbanView spec'ы получили setActivePinia.

Регресс: lint+type-check+format ; vitest 245/245 за 17.07 сек (+3);
vite build 1.04 сек; Pint+PHPStan passed; Pest 156/156 за 20.27 сек
(+15 от 141, 675 assertions). Реестр v1.56→v1.57, CLAUDE.md v1.47→v1.48.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:43:21 +03:00
Дмитрий 4a385b1df7 phase2(prod-tightening): HMAC+rate-limit webhook / fetch system_settings / CSV export
3 production-tightening после 7-фичного пакета v1.55.

(1) HMAC + per-token rate-limit для webhook receive endpoint:
- WebhookReceiveController::receive: tenant lookup → rate-limit → HMAC
  → payload validation.
- HMAC: опциональный X-Webhook-Signature: sha256=<hex> через hash_hmac +
  hash_equals (constant-time). Backward-compat: header missing → 202.
- Per-token rate-limit: RateLimiter с decay 60 сек. Лимит из
  system_settings.webhook_rate_limit_rps × 60. На превышении 429 +
  Retry-After. Hit ставится ДО валидации payload — иначе обходимо 422.
- Pest +5: HMAC valid/invalid 401/missing 202; rate-limit 60+1=429;
  ключ изолирован per-token.

(2) Реальный fetch system_settings в AdminSystemView:
- onMounted → adminApi.listSystemSettings() → splice replace.
- На fetch-error → fallback на mock + warning v-alert.
- Кнопка «Обновить» — ручной reload.
- Vitest +3: mount fetch / reload / error fallback.

(3) Реальный CSV-export для bulk-actions DealsView:
- applyBulkExport → CSV через Blob+a[download].
- 8 колонок, ; разделитель, \r\n, BOM через String.fromCharCode(0xFEFF)
  (литеральный U+FEFF блокируется ESLint no-irregular-whitespace).
- Filename deals_export_YYYY-MM-DD.csv.
- Empty selection → toast без download.
- Vitest +2: spy createObjectURL+anchor.click; empty без blob.

PHPStan baseline регенерирован.

Регресс: lint+type-check+format ; vitest 242/242 за 15.82 сек (+4);
vite build 903 ms; Pint+PHPStan passed; Pest 141/141 за 17.8 сек (+5,
627 assertions). Реестр v1.55→v1.56, CLAUDE.md v1.46→v1.47.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 05:49:34 +03:00
Дмитрий 768628d914 phase2(7-features): bulk-actions / new-deal / tenant-card / system-edit / webhook / smart-filters / impersonation-list
7-фичный auto-mode пакет согласно «карте что осталось» (после v1.54).

(1) Bulk-actions DealsView:
- dealsState reactive-копия MOCK_DEALS (deep-clone) для безопасного bulk-edit.
- Bulk-bar (sticky, теало-нуар, theme=dark) при selected.length > 0:
  count + Сменить статус (v-menu × 14 lead_statuses) + Экспорт (snackbar) +
  Удалить (v-dialog confirm) + ✕ clear.
- На production: smart status-transition с проверкой allowed-переходов;
  soft-delete (архив 30 дней); реальный CSV/XLSX export через xlsx-lib.

(2) NewDealDialog (used in DealsView+KanbanView):
- 6 полей: name/phone/project (MOCK_PROJECTS) / manager (MOCK_MANAGERS) /
  cost / status (default 'new' или presetStatus). Phone-валидация ≥10 цифр.
- emit('created', deal) → DealsView push в начало dealsState; KanbanView push
  в правильную колонку по statusSlug + totalDeals++.

(3) AdminTenantDetailView (/admin/tenants/:code):
- 4 KPI cards (Баланс/runway / Тариф+MRR/мес / Лиды сегодня+неделя+месяц /
  Средняя цена). 4 v-tabs: Финансы (balance-history) / Пользователи /
  Проекты / Активность с event-кодами.
- Кнопка «Войти как клиент» (использует ImpersonationDialog из v1.54).
  404-fallback. composables/mockTenantDetail.ts с expandTenantDetail.
- AdminTenantsView получил @click:row → router.push.

(4) Edit-flow AdminSystemView (audit-log + 2-step):
- Backend: SystemSetting + SaasAdminAuditLog Eloquent (append-only,
  payload_before/after JSONB casts).
- AdminSystemSettingsController с GET (list) + PUT (update в DB::transaction
  + INSERT в saas_admin_audit_log; hash-chain trigger BEFORE INSERT
  заполняет log_hash).
- Type-validation: int/decimal/bool/json. Reason ≥30 chars. No-op → 422.
- Frontend SystemSettingEditDialog — 3-step (edit → confirm с diff
  before/after → done).

(5) Webhook receive endpoint (POST /api/webhook/{token}):
- WebhookReceiveController::receive. Token = tenants.webhook_token.
- 404 unknown / 422 bad payload / 202 success + dispatch ProcessWebhookJob.
- Stub-INSERT в webhook_log через DB::table обёрнут в DB::transaction +
  SET LOCAL app.current_tenant_id для RLS.
- CSRF-исключение для api/webhook/* в bootstrap/app.php.
- На prod: + HMAC X-Webhook-Signature + per-token rate-limit.

(6) Smart-filters:
- DealsView: multi-select v-select Проект+Менеджер с auto availableProjects/
  availableManagers computed.
- AdminTenantsView: filterStatuses (4 STATUS_OPTIONS) + filterTariffs
  (computed availableTariffs).
- Кнопка «Сбросить» появляется только когда фильтры активны.

(7) AdminImpersonationView (/admin/impersonation):
- Backend +2 GET endpoints: /active (used_at != null AND session_ended_at
  == null) + /recent (last 20 завершённых с duration_seconds через
  abs(diffInSeconds) — Carbon signed по умолчанию).
- ImpersonationToken получил belongsTo(Tenant).
- Frontend view: 2 секции (Активные с end-кнопкой / Недавно завершённые
  read-only) + refresh + onMounted load.
- Маршрут /admin/impersonation + 5-й nav-пункт «Impersonation» в AdminLayout.

Vitest +48 (всего 238/238 за 15.31 сек).
Pest +16 (всего 136/136 за 15.8 сек, 495 assertions).
PHPStan baseline регенерирован (0 errors после фикса nullsafe.neverNull).

Регресс: lint+type-check+format ; vite build 937 ms; Pint+PHPStan passed;
Pest 136/136. Реестр v1.54→v1.55, CLAUDE.md v1.45→v1.46.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 05:33:21 +03:00
Дмитрий 61afa72591 phase2(impersonation-ui): UI dialog для Ю-1 в AdminTenantsView (frontend)
Закрывает TODO из v1.44 — frontend для Impersonation backend (`1963694`).

api/admin.ts:
- impersonationInit/Verify/End — типизированные axios-helpers для трёх
  endpoint из v1.53. Все три — ensureCsrfCookie + apiClient.post.
  На prod автоматически перейдут под middleware('auth:saas-admin').

components/admin/ImpersonationDialog.vue — 4-step state-machine:
- step 1 «reason»: v-textarea ≥30 chars + counter + hint «Ещё N символов».
- step 2 «verify»: info-alert email клиента + 6-digit input
  (autocomplete=one-time-code) + dev-banner с _dev_plain_code.
- step 3 «active»: success-alert + кнопка «Завершить сессию».
- step 4 «done»: финальный success.
- persistent dialog (нельзя закрыть кликом за пределами — audit trail).
- watch(modelValue) сбрасывает state при каждом открытии.

AdminTenantsView:
- 8-я колонка actions (width=56) с v-tooltip + icon-btn mdi-account-switch.
- :disabled на suspended (по ТЗ §22.7 — только активные tenant'ы).
- @click.stop, data-testid=impersonate-btn-{id}.
- ADMIN_USER_ID=1 заглушка (на prod удалится — backend возьмёт из auth).

Vitest +11 (всего 190/190 за 13.23 сек):
- ImpersonationDialog.spec.ts (7): hide когда modelValue=false; step-1 mount;
  reason<30 показывает counter; init→step2 (email+dev-banner); verify→step3
  (end-btn); 5-digit code не вызывает API; end→step4; Cancel emit.
- AdminTenantsView.spec.ts (+4): impersonate-btn в каждой строке; suspended
  disabled; click открывает диалог с правильным tenant; props.requestedBy=1.

Vitest quirk: v-dialog/v-tooltip требуют layout-injection — stub'ы
VDialog как passthrough <div v-if="modelValue"><slot/></div>, VTooltip как
<div><slot name="activator" :props="{}"/></div>. ImpersonationDialog
stub'ится в AdminTenantsView spec. api/admin + helpers extractValidationErrors/
extractErrorMessage мокаются через vi.mock — axios.isAxiosError(plain Error)
в jsdom возвращает false (паттерн из auth-store.spec.ts).

Production TODO: SaaS-admin auth (Yandex 360 SSO, Б-1) → middleware,
two-person approval (CTO-15/Ю-9), MailService → _dev_plain_code исчезает,
live cookie-swap session, страница «Активные impersonation-сессии».

Регресс: lint+type-check+format+build OK (924 ms; AdminTenantsView lazy-chunk
20.68 KB включает inline ImpersonationDialog); Vitest 190/190 за 13.23 сек;
Pest 120/120 за 15.69 сек (нетронут). Реестр v1.53→v1.54, CLAUDE.md v1.44→v1.45.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:52:52 +03:00
Дмитрий f65b2ca8d8 phase2(admin-views): AdminBilling/Incidents/System — реальные display-views
- AdminBillingView: 4 stats (MRR, Выручка, Просрочка, Возвраты) + v-data-table 7 колонок (Тенант с ИНН / Тариф / Баланс с error-color / пополнения / списания / MRR / Статус-chip) + поиск
- AdminIncidentsView: 3 stats + 5 фильтров статуса + v-list с incident_id (INC-YYYY-MMDD-NNNN) + severity/status/РКН-pending chips + дедлайн 24ч по 152-ФЗ
- AdminSystemView: read-only warning + поиск + v-list 7 system_settings (webhook_rate_limit, login_max_attempts, retention и т.д.) с type-chip и updated_at
- composables/mockAdmin.ts: AdminBillingTenantRow + AdminIncidentRow + AdminSystemSetting + mock-данные
- Router: /admin/{billing,incidents,system} → реальные views (не placeholder)
- Vitest +13 (179/179 за 11.98с)
- TODO: edit-flow для system_settings + backend /api/admin/* endpoints
- Регресс: lint+type+format OK; build 743ms; story:build 21/28 за 31.5с
- CLAUDE.md v1.42→v1.43, реестр v1.51→v1.52

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:17:17 +03:00
Дмитрий 73e64128dc phase2(2fa-setup): wizard init+confirm+disable+regenerate в SettingsView/SecurityTab
- TwoFactorSetupController (auth:sanctum): /api/2fa/{init,confirm,disable,regenerate-recovery-codes}
- init секрет в session (не в БД), QR-URL otpauth://; confirm активирует 2FA + 8 recovery codes
- disable/regenerate требуют password-confirmation
- User.casts: totp_secret => encrypted

Schema v8.7→v8.8: users.totp_secret VARCHAR(255) → TEXT (encrypted ~256 chars)
Migration fix: explicit ALTER TABLE webhook_dedup_keys ADD FK после DB::unprepared (PDO глотал FK на partitioned)
PartitionsCreateMonthsTest fix: DETACH PARTITION + DROP вместо DROP CASCADE

Frontend: SecurityTab реальная логика (setup wizard 3 шага, disable, regenerate dialogs)

- Pest +10 (101/101 за 13.37с, 364 assertions)
- Vitest 166/166
- CLAUDE.md v1.39→v1.40, реестр v1.48→v1.49, schema v8.7→v8.8

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:03:02 +03:00
Дмитрий c39d555e6f phase2(recovery-code): POST /api/auth/2fa/recovery-use + UseRecoveryCodeView
- AuthController::useRecoveryCode перебирает unused codes через Hash::check, нормализация (lowercase + remove dash/space)
- UserRecoveryCode Eloquent (UPDATED_AT=null — schema без updated_at)
- Rate-limit auth:recovery:{pending_user_id}|{ip} (5/15мин)
- Returns recovery_codes_remaining для UI-warning'а (sessionStorage на frontend)
- UseRecoveryCodeView.vue → POST /api/auth/2fa/recovery-use, /recovery-use route, autocomplete=one-time-code
- TwoFactorView "резервный код" ссылка /recovery → /recovery-use
- Pest +6 RecoveryCodeTest (91/91 за 12.77с, 319 assertions)
- Vitest +6 (166/166 за 11.47с)
- TODO: #3 2FA setup wizard (после этого /recovery view получит реальный source данных)
- Регресс: lint+type+format OK; build 849ms; story:build 21/28 за 30.36с; Pint+Stan passed
- CLAUDE.md v1.38→v1.39, реестр v1.47→v1.48

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:43:58 +03:00
Дмитрий 9c488122a1 phase2(reset-password): POST /api/auth/reset-password + ResetPasswordView + DB timezone fix
- AuthController::resetPassword через Password::reset() (callback пишет password_hash)
- ResetPasswordRequest: token + email + password (min 10 по ТЗ §22.4.1) + confirmed
- Rate-limit auth:reset:{sha256(token)[0..16]}|{ip} (5/15мин)
- ResetPasswordView для deep-link /reset/:token?email=...; pre-fill email из query; success → redirect /login через 3 сек
- Vue Router /reset/:token (guestOnly); web.php /reset SPA-path
- DB FIX: config/database.php pgsql.timezone=UTC — без него PG TIMESTAMPTZ +03 терялся при Carbon::parse и tokenExpired ошибочно срабатывал
- Pest +6 ResetPasswordTest (85/85 за 11.50с, 291 assertions)
- Vitest +7 (160/160 за 11.02с)
- Регресс: lint+type+format OK; build 784ms; story:build 21/28 за 30.74с; Pint+Stan passed
- CLAUDE.md v1.37→v1.38, реестр v1.46→v1.47

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:36:27 +03:00
Дмитрий 170382878b phase2(forgot-password): POST /api/auth/forgot + ForgotPasswordView интеграция
- AuthController::forgotPassword использует Password::sendResetLink (anti-enumeration: всегда 200)
- AUTH_PASSWORD_RESET_TOKEN_TABLE=password_resets — указывает на нашу таблицу из schema v8.7
- Rate-limit 5/15мин по auth:forgot:{email}|{ip} — hit ставится ДО sendResetLink (защита перебора через unknown email)
- Frontend: authApi.forgotPassword, auth-store.requestPasswordReset, ForgotPasswordView success-state
- Pest +6 в ForgotPasswordTest (79/79 за 10.55с, 273 assertions)
- Vitest +4 (153/153 за 11.11с)
- TODO: POST /api/auth/reset-password + UI-форма new_password (deep-link)
- Регресс: lint+type+format OK; build 862ms; story:build 21/28 за 32с; Pint+Stan passed
- CLAUDE.md v1.36→v1.37, реестр v1.45→v1.46

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:10:28 +03:00
Дмитрий 75897b1636 phase2(rate-limit): login + 2FA verify (5/15min) + frontend lockout
- AuthController: RateLimiter::hit/clear на login + verifyTwoFactor по ключу email|ip / pending_user_id|ip
- 429 + Retry-After header + JSON retry_after (lockoutResponse helper)
- ТЗ §22.4.4: 5 попыток / 15 мин; success чистит throttle; inactive user тоже расходует попытки
- extractRateLimitRetry в api/client.ts; auth-store.lockoutSeconds; v-alert в LoginView/TwoFactorView
- Pest +6 в RateLimitTest.php (73/73 за 8.07с, 246 assertions)
- Vitest +4 в auth-store + LoginView (149/149 за 12.31с)
- Quirk: wrong-password в тестах ≥8 символов (LoginRequest::min:8) — иначе валидация падает до controller
- Quirk: vi.mock api/client в auth-store.spec — иначе axios.isAxiosError в jsdom возвращает false для plain Error
- TODO (отдельные коммиты): IP-lockout 10/час через auth_log + email при 3 неудачах
- Регресс: lint+type+format OK; build 886ms; story:build 21/28 за 37.19с; Pint+Stan passed
- CLAUDE.md v1.35→v1.36, реестр v1.44→v1.45

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:49:47 +03:00
Дмитрий 1d1353931d phase2(user-chip): реальный user в AppLayout/AdminLayout + Logout-menu
- AppLayout: userInitials/userShortName computed из auth-store, fallback цепочка → email → '?' / 'Гость'
- AdminLayout: тот же паттерн с админ-defaults 'АО' / 'Админ Оператор'
- v-menu offset=8 на user-chip: email + Настройки/Выйти из админки + Выйти
- handleLogout async: auth.logout() (swallows API errors) → router.push('/login')
- Vitest +3 в AppLayout.spec.ts (всего 145/145): store-mock + null-user + email-fallback
- AppShell.spec.ts получил createPinia в plugins
- Регресс: lint+type+format OK, vitest 145/145 за 11.01с, build 855ms, story:build 21/28 за 32.11с, Pest 67/67 за 6.16с
- CLAUDE.md v1.34→v1.35, реестр v1.43→v1.44

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:29:05 +03:00
Дмитрий 374724a7a3 phase2(auth-2fa): TOTP-verify endpoint + TwoFactorView интеграция
- pragmarx/google2fa@^9.0 для TOTP RFC 6238.
- AuthController::login изменён: при totp_enabled=true НЕ делает Auth::login,
  сохраняет auth.pending_user_id+pending_remember в session, возвращает
  requires_2fa=true. /me=401 пока 2FA не пройдена.
- AuthController::verifyTwoFactor: читает pending_user_id, верифицирует TOTP
  через Google2FA::verifyKey($secret, $code, window: 1) (окно ±1 = 30s).
  Success → Auth::login + regenerate + clear pending + last_login_at.
- VerifyTwoFactorRequest: regex /^\d{6}$/.
- /api/auth/2fa/verify публичный (нет session-auth до verify).

Frontend:
- auth-store::login: при requires_2fa=true user остаётся null (иначе
  isAuthenticated=true и guard пустит на /dashboard минуя 2FA).
- auth-store::verifyTwoFactor action.
- api/auth.ts::verifyTwoFactor(code).
- TwoFactorView: onMounted redirect на /login если нет pending state;
  submit → verify → /dashboard; на error - clear code + focus first cell.
  userEmail из auth.user?.email.

Pest +6 (всего 67/67 за 6.97s, 194 assertions): login для 2FA НЕ создаёт
session + verify success/неверный код/без login/валидация формата +
после verify /me=200.

Vitest +3 (всего 142/142 за 10.75s): login pending vs success state +
verifyTwoFactor success/reject. TwoFactorView spec получил setActivePinia
+ requires2fa=true для bypass onMounted-redirect.

PHPStan baseline +26 Pest TestCall warnings (накопительно).

Регресс: pint+stan passed; vitest 142/142; vite build 908ms;
story:build 21/28 за 31.28s; Pest 67/67 за 6.97s.

CLAUDE.md v1.33->v1.34, реестр Открытых_вопросов v1.42->v1.43.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:14:33 +03:00
Дмитрий 59299d3c2b phase2(auth-frontend): axios + Pinia + auth-store + auth-guards + form integration
- axios@^1.16 + pinia@^3.0 (--legacy-peer-deps).
- api/client.ts: axios с withCredentials+withXSRFToken (Sanctum SPA auto-XSRF).
  ensureCsrfCookie() + extractValidationErrors/Message helpers.
- api/auth.ts: типизированные login/register/me/logout с AuthUser interface.
- stores/auth.ts: Pinia composition-store (user/loading/requires2fa +
  isAuthenticated computed + login/register/fetchMe/logout actions).
  logout() catch-swallow - UI всегда выходит локально.
- LoginView/RegisterView: useAuthStore интеграция, real POST через store,
  errors из 422 на v-text-fields, redirect на /dashboard или /2fa,
  :loading на btn'ах.
- Auth-guard в router.beforeEach: meta.requiresAuth на 10 routes
  (6 app + 4 admin), meta.guestOnly на login/register/forgot. При первом
  переходе fetchMe() restore-session. Unauth → /login?redirect=<original>.
- / redirect → /dashboard (auth-guard перехватит если не залогинен).
- Pinia в app.ts через app.use(createPinia()).
- cspell-words.txt: мокаем.

Vitest +10 (всего 139/139 за 10.11s):
- auth-store 7 (initial state + login success/reject + register + fetchMe
  success/401 + logout swallow).
- router 5 переписан (login.guestOnly + 6 protected + admin layout +
  3 error без auth + unauth /dashboard → /login?redirect).
- LoginView/RegisterView/router тесты получили createPinia в plugins.
- vi.mock api/auth в router+auth-store specs.

Регресс: lint+type+format OK; vitest 139/139; vite build (main app-chunk
105→153.64 KB +axios+pinia+auth gzipped 54.54 KB) 806ms; story:build 21/28
за 31.73s; Pest 61/61 за 5.86s.

CLAUDE.md v1.32->v1.33, реестр Открытых_вопросов v1.41->v1.42.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:59:43 +03:00
Дмитрий da65cf4bf7 phase2(admin): AdminLayout + AdminTenantsView - админка SaaS (12/13 концептов)
- AdminLayout: отдельный sidebar теало-нуар с под-брендом ADMIN (red error
  10px JBM uppercase) + 4 nav (Тенанты 142 / Биллинг / Инциденты 3 / Система) +
  topbar с crumb «Админка → currentPage» + admin-user-chip с error-color avatar.
- AdminTenantsView (/admin/tenants): page-head + 5-stats + Экспорт +
  search/Статус/Тариф фильтры + v-data-table 7 колонок (Тенант с двухстрочным
  name+inn / Статус-chip 4 цвета / Тариф / Баланс ₽ с error-color при <0 /
  Желаем×факт / MRR с «—» / Активность).
- mockTenants.ts соответствует schema v8.7 §3: 4 статуса × 5 тарифов, 7 mock
  с разнообразием (active/trial/overdue/suspended) + AdminStats (142/128/9/5/
  1 248 600 ₽).
- AdminPlaceholderView универсальный для Биллинг/Инциденты/Система с
  описаниями ссылающимися на schema v8.7 (incidents_log §9 / system_settings §10).
- AppShell расширен meta.layout='admin'. Router: /admin redirect на /tenants +
  4 admin-route'а с lazy-imports. Web.php fallback покрывает /admin/*.
- cspell-words.txt: Екб.

Vitest +11 (всего 129/129 за 10.02s):
- заголовок + 5 stats (regex nbsp в 1 248 600 ₽) + 7 columns + 7 rows +
  Окна Москва ИНН + overdue −1 200 + trial 4 дня + suspended + search filter
  «Натяжные» → 1 row + Экспорт/Статус/Тариф кнопки.

Регресс: lint+type+format OK; vitest 129/129; vite build (admin views
в lazy-chunks; main 104.99 KB); story:build 21/28 за 30.32s; Pest 48/48 за 4.89s.

CLAUDE.md v1.30->v1.31, реестр Открытых_вопросов v1.39->v1.40.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:23:28 +03:00
Дмитрий 034657788d phase2(errors): ErrorView 404/403/500 + Laravel fallback
- ErrorView универсальный с конфигурацией через route.meta.errorCode
  (404/403/500). По v8_errors.html: full-bleed теало-нуар bg, top-brand,
  err-code 96px JBM с accent на средней цифре, title/desc, 2 actions,
  опциональные status-list (500) и err-id с copy-btn (403/500).
- AppShell: meta.layout='error' → RouterView напрямую (ErrorView сам
  предоставляет v-app).
- Router: /403, /500, catch-all /:pathMatch(.*)*  → ErrorView с meta.errorCode.
- web.php: явные Route::view + Route::fallback (срабатывает после Pest
  runtime-routes, не ломает SetTenantContextTest).
- cspell-words.txt: резолвится, роуты.

Vitest +8 (всего 118/118 за 9.39s):
- 404 default + 403 с REQ-ID + 500 с INC-ID + status-list (API/Telegram/YooKassa) +
  404 actions (На дашборд + Назад) + 403 mailto-link + 500 status-link +
  brand-блок + 404 НЕ содержит REQ/INC/status-list (regression-guard).
- stubs:{VApp/VMain} как passthrough — обходим Vuetify layout-injection в jsdom.

Регресс: lint+type+format OK; vitest 118/118; vite build (ErrorView lazy-chunk;
main app-chunk 101.01KB упал на 7KB благодаря shared chunk'ам); story:build
19/26 за 30.96s; Pest 48/48 за 4.88s (fallback не сломал runtime-routes).

CLAUDE.md v1.29->v1.30, реестр Открытых_вопросов v1.38->v1.39.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:11:09 +03:00
Дмитрий 4de39e70b2 phase2(reports): ReportsView - асинхронная генерация отчётов с очередью
- ReportsView (/reports): form-card (Запросить) + jobs-list panel.
  Form: 4 type-cards radio-grid (Сделки/Менеджеры/Источники/Биллинг) +
  date-range + Проект/Менеджер v-select + 4 fmt-кнопки (CSV/XLSX/JSON/PDF) +
  quota-banner alert (CTO-7: 2/3 одновременных + CTO-6: 3 попытки/7 дней) +
  Запустить/Сброс.
- Jobs-list: 5 mock-rows × 4 статуса (done/running/queued/failed) с icon +
  meta JBM (FORMAT · size · rows · timeText) + status-chip + actions
  (Скачать done / Повторить failed && attempt<3 / Отменить queued /
  Удалить done|failed). v-progress-linear для running 62%.
- composables/mockReports.ts: type unions (4×4×4) + 5 mock-jobs + MOCK_QUOTA
  (CTO-6/7 значения).
- Маршрут /reports (meta.layout=app, lazy-import) в router + web.php.

Vitest +12 (всего 110/110 за 9.38s):
- заголовок + page-stats + 4 type-cards + дефолт active + 4 формата +
  quota-banner («2 из 3» / «3 попыток retry» / «7 дней») + 5 job-rows +
  done-«Готов»+Скачать-aria + running-«62%»+progressbar role + queued-Отменить +
  failed-«Ошибка»+«S3 timeout»+Повторить-aria + клик-переключение active.

Регресс: lint+type+format OK; vitest 110/110; vite build (ReportsView lazy-chunk;
main 108.19 KB); story:build 18/25 за 30.77s; Pest 48/48 за 4.58s.

CLAUDE.md v1.28->v1.29, реестр Открытых_вопросов v1.37->v1.38.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:01:10 +03:00
Дмитрий 394663597f phase2(settings): SettingsView - 8 вкладок (4 реализованы, 4 placeholder)
- SettingsView (/settings): sidebar tabs-rail (md=3, 8 v-list-item с mdi-icon)
  + content-pane (md=9 v-card outlined min-height 480px). activeTab ref
  переключает рендер вкладки.

Реализованы:
- ProfileTab: avatar 80px + 5 form-fields (имя/email disabled/телефон/TZ/роль).
- SecurityTab: 3 cards (Пароль / 2FA включена + recovery codes + Отключить /
  Активные сессии 3 mock с Завершить-btn).
- ApiTab: API-ключ password+eye-toggle + Webhook (URL + signing secret HMAC).
  Текст про дедуп (tenant_id, source_crm_id) 24ч и антифрод по phone (§10.8.1).
- NotificationsTab: матрица 8x3 (events × channels) соответствует schema v8.7
  §4 users.notification_preferences JSONB. 8 событий (new_lead, duplicate_detected,
  low_balance, tariff_charge, reminder_due, manager_assigned, webhook_failed,
  monthly_report) × 3 канала (email/sms/in_app). + sound_enabled switch.

Placeholder:
- PlaceholderTab универсальный с props title/description + v-alert «В разработке».
- Используется для Проекты / Команда / Интеграции / Тихие часы.

Маршрут /settings (meta.layout=app, lazy-import) в router + web.php.
.gitleaks.toml: settings/*.vue в allowlist (фиктивный профиль).
cspell-words.txt: смыслово.

Vitest +8 (всего 98/98 за 8.42s):
- 8 nav-tabs + все названия + дефолт «Профиль» + Проекты → «В разработке» +
  Уведомления показывает «События × каналы» + 5 событий матрицы +
  Безопасность: 2FA + сессии + API: API-ключ + Signing secret HMAC.

Регресс: lint+type+format OK; vitest 98/98; vite build (SettingsView lazy-chunk;
main app-chunk 107.85KB); story:build 17/24 за 31.7s; Pest 48/48 за 5.03s.

CLAUDE.md v1.27->v1.28, реестр Открытых_вопросов v1.36->v1.37.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:51:41 +03:00
Дмитрий c8012896e3 phase2(billing): BillingView - финансовый экран биллинга и тарифов
- BillingView (/billing): page-head со stats (кошелёк/лиды/runway-дни) + pending
  banner v-alert info («1 платёж в обработке через ЮKassa, auto-cancel 30 мин»)
  + 3 wallet-cards (Кошелёк ₽ primary card теало-нуар + LIVE; Баланс лидов
  ГЦК; Тариф «Команда» 990₽/мес + 3 фичи) + transactions panel (4 tabs +
  v-data-table 5 колонок: Дата/Операция/ID/Статус-chip/Сумма ± JBM tnum) +
  invoices list (PDF + 1С 8.3 XML).
- composables/mockBilling.ts соответствует схеме v8.7 §4.4-4.5: 8 mock
  транзакций (types: topup/lead_charge/refund/tariff_charge; statuses:
  pending/completed/rejected) + 4 invoices (pdf/xml_1c83) + pending payment.
- Маршрут /billing (meta.layout=app) в router + web.php.

Format helpers: «+ N ₽» / «− N ₽» / «— 0 ₽» rejected; Intl.NumberFormat ru-RU.

Vitest +11 (всего 90/90 за 7.96s):
- заголовок + page-stats nbsp regex + pending banner + 3 wallet-cards + 3 фичи
  тарифа + 4 tabs + дефолт «Все» 8 строк + format «+/−» + rejected «— 0 ₽» +
  4 invoice rows + PDF/1С 8.3 XML labels.

Регресс: lint+type+format OK; vitest 90/90; vite build (BillingView lazy-chunk;
VDataTable вынесен в общий chunk 79.84KB - shared с DealsView); story:build
16/23 за 32.16s; Pest 48/48 за 4.89s.

CLAUDE.md v1.26->v1.27, реестр Открытых_вопросов v1.35->v1.36.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:39:49 +03:00
Дмитрий 45239f6602 phase2(deal-drawer): DealDetailDrawer - правая панель с деталями сделки
- DealDetailDrawer (v-navigation-drawer right temporary 480px):
  - hero (#id eyebrow + name h5 + close + tel:link + clock + status-chip)
  - section Параметры (2-col grid: Проект/Стоимость/Менеджер/Источник)
  - section Активность (timeline 6 events с iconified vertical-line)
- mockDealEvents.ts: 6 mock-events (created/balance_charged/assigned/viewed/
  status_changed/commented) - соответствуют ActivityLog event-константам v8.7.
- Интеграция в DealsView (@click:row) и KanbanView (через @open-deal от карточки).
- cspell-words.txt: iconified, мапы, резолвятся, резолвером, stub'ить, инлайнен.

Vue3 quirk: v-navigation-drawer требует layout-injection от v-app/v-layout,
но в Vitest vite-plugin-vuetify auto-import не работает. Решение:
- DealsView/KanbanView тесты: stubs:{DealDetailDrawer:true}
- DealDetailDrawer тесты: stubs:{VNavigationDrawer:passthrough-div}

Vitest +8 (всего 79/79 за 7.57s):
- DealDetailDrawer 8 (open=false скрытие, deal=null no-content, hero+id,
  tel:link, status-chip, params, timeline 6 items, emit update:open(false)).

Регресс: lint+type+format OK; vitest 79/79; vite build (drawer инлайнен в
DealsView+KanbanView lazy-chunks); story:build 15/22 за 31.55s; Pest 48/48.

CLAUDE.md v1.25->v1.26, реестр Открытых_вопросов v1.34->v1.35.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:29:11 +03:00
Дмитрий c8131a39c8 phase2(kanban-dnd): vuedraggable@4 - drag-and-drop карточек между 14 колонками
- vuedraggable@^4.1.0 + sortablejs@1.14.0 (--legacy-peer-deps).
- KanbanColumn: <draggable v-model="localDeals" group="kanban-deals"
  item-key="id" ghost-class="ghost-card" drag-class="drag-card" animation="150">
  + #footer empty-state «пусто · перетащите сюда».
- DraggableChangeEvent типизирован (added/removed/moved discriminated union).
- KanbanView: const → reactive<Record<slug, MockDeal[]>> (vuedraggable v-model
  требует независимые arrays); shallow-clone {...d} чтобы не мутировать MOCK_DEALS.
- onColumnChange: при event.added → element.statusSlug = targetSlug.
  TODO: POST /api/deals/{id}/transition с проверкой allowed-переходов.
- cspell-words.txt: vuedraggable, симулируется.

Visual: ghost-card opacity 0.4 + ivory-tint bg, drag-card rotate 1deg.

Vitest +1 (всего 71/71 за 7.48s): эмулирует $emit('change', {added}) →
проверяет statusSlug update. Полный mouse-DnD не симулируется — JSDOM
не умеет drag-events, но event-handler логика покрыта.

Регресс: lint+type+format OK; vitest 71/71; vite build (KanbanView lazy-chunk
вырос до 180.53KB - SortableJS-обёртка, грузится только на /kanban);
story:build 14/20 за 30.45s; Pest 48/48 за 4.88s.

CLAUDE.md v1.24->v1.25, реестр Открытых_вопросов v1.33->v1.34.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:08:16 +03:00
Дмитрий d39934c8d9 phase2(kanban): KanbanView - 14 колонок по lead_statuses (БЕЗ DnD)
- KanbanCard: компактная карточка (name/phone/project/cost/manager-avatar),
  emit('open',id) на click для будущего DealDetailDrawer.
- KanbanColumn: header с border-top по colorHex статуса (--accent CSS-var) +
  name+count+total ₽; body с v-for карточек + empty-state «пусто».
- KanbanView: orchestrator, 14 колонок (по LEAD_STATUSES) с группировкой
  MOCK_DEALS по statusSlug, horizontal-scroll с custom scrollbar.
- Маршрут /kanban (meta.layout=app) в router + web.php.
- .gitleaks.toml: tests/Frontend/*.spec.ts в allowlist (assertion на mock-телефоны).
- cspell-words.txt: инлайн, vueuse.

DnD НЕ реализован на MVP - отдельный коммит после выбора библиотеки
(vue-draggable-next или @vueuse/integrations/useSortable).

Vitest +14 (всего 70/70 за 7.37s):
- KanbanCard 3 (data + initials + emit open)
- KanbanColumn 5 (header + total + empty + accent CSS-var case-insensitive +
  проброс openDeal)
- KanbanView 6 (заголовок + 14 columns + правильные status'ы + stats + кнопка +
  DnD-предупреждение)

Регресс: lint+type+format OK; vitest 70/70; vite build (KanbanView lazy-chunk);
story:build 14/20 за 31.17s; Pest 48/48 за 5.06s.

CLAUDE.md v1.23->v1.24, реестр Открытых_вопросов v1.32->v1.33.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:56:59 +03:00