Commit Graph

83 Commits

Author SHA1 Message Date
Дмитрий 84530d55bf test(projects): fix ProjectCard change-trigger target
Pre-existing failing test from commit c9ee8d8 — data-testid lives on
<label>, but @change handler sits on <input> inside it. jsdom does not
bubble change-event from label to input via @vue/test-utils trigger.
Use child-input selector to fire the event on the right node.

Baseline после fix: 614 passed / 3 skipped / 0 failed (vs 613 / 3 / 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:36:33 +03:00
Дмитрий df92ac02ff feat(projects-bulk): wire 3 new dialogs into BulkActionsBar
Add RegionsBulkDialog / DaysBulkDialog / LimitBulkDialog to
BulkActionsBar with open-state refs (regionsOpen/daysOpen/limitOpen),
runBulk helper via store.bulkUpdate, and flex-wrap layout.
Update spec: fix existing tests (bulkAction → bulkUpdate), add 3 new
dialog-wiring tests (7/7 pass; full suite 614+3skipped/0failed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:23:29 +03:00
Дмитрий 4b6ab8f113 feat(projects-bulk): LimitBulkDialog delta or replace mode
Delta mode combines Add/Remove numeric inputs into a single signed delta;
Replace mode switches to an absolute value input via v-checkbox toggle.
5/5 Vitest pass; full suite 611 passed + 3 skipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:20:18 +03:00
Дмитрий 4c470813b4 feat(projects-bulk): DaysBulkDialog Add/Remove (7 weekday bitmask)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:17:16 +03:00
Дмитрий 3b254fb56f feat(projects-bulk): RegionsBulkDialog Add/Remove (8 ФО bitmask) 2026-05-12 15:14:18 +03:00
Дмитрий 95bba384a1 feat(projects-bulk): select-all toolbar with counter and indeterminate state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:11:21 +03:00
Дмитрий a46e63bdd3 feat(projects-bulk): store selectAllByFilter + bulkUpdate with scope discriminator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:08:08 +03:00
Дмитрий 5c8ad2738a feat(layout): dark topbar + sidebar cleanup + DevIndexBadge moved below
Sidebar: убраны Менеджеры/Напоминания; Работа в порядке
Проекты/Сделки/Канбан/Дашборд; Команда — только Настройки;
снят useRemindersStore (был только под reminders badge).

Topbar: тёмный фон linear-gradient(noir → #04261E) совпадающий
с sidebar #1271; убран breadcrumb «Рабочая область»;
v-toolbar__content padding-left:240 (не уходит под sidebar).

DevIndexBadge: top:64 (ниже топбара, не перекрывает user-chip).

Vitest AppLayout 15/15 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:32:03 +03:00
Дмитрий d238ca5f4a feat(dev-indices): overlay Alt-keys (up/down) + Alt+Shift+I toggle + mini-badges
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:05:53 +03:00
Дмитрий d8c33b4cd6 feat(dev-indices): DevIndexOverlay (hover badge + click-copy + Esc + AppShell mount)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:02:38 +03:00
Дмитрий 901530ae41 feat(dev-indices): useDevIndices composable (state + DOM walk)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:57:35 +03:00
Дмитрий e2669270f3 feat(redesign): Task 17 — ProjectCard tokens (hover lift + JetBrains Mono numerics) 2026-05-12 10:22:36 +03:00
Дмитрий 22e6bdf8b8 feat(redesign): Task 16 — KanbanView StatusPill + hover lift (motion #4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:19:21 +03:00
Дмитрий 2f46a3e5ec feat(redesign): Task 15 — DealsView filterbar + density + StatusPill + hover lift (motion #2,#4) 2026-05-12 10:13:19 +03:00
Дмитрий 35662f7b56 feat(redesign): Task 14 — DashboardView KPI count-up (motion #1) + live pulse 2026-05-12 10:06:08 +03:00
Дмитрий a09434eca0 feat(redesign): Task 13 — page transition wiring (Vue Transition + CSS fadeup, motion #6) 2026-05-12 09:59:22 +03:00
Дмитрий 3f956224bd feat(redesign): Task 12 — AppSidebar двухтоновый shell + ⌘K stub + active marker (motion #7) 2026-05-12 09:54:54 +03:00
Дмитрий 2707ff64ab feat(redesign): Task 11 — DensityToggle component (compact/comfortable + persist) 2026-05-12 09:49:37 +03:00
Дмитрий 0b2ec5b802 feat(redesign): Task 10 — FilterChip component (label + count + active states) 2026-05-12 09:47:02 +03:00
Дмитрий 52cc64c9e6 feat(redesign): Task 9 — Kbd component (⌘K, Esc badges; light+dark variants) 2026-05-12 09:45:22 +03:00
Дмитрий ff3bc8bcc1 feat(redesign): Task 8 — StatusPill component + 14-variant Histoire story 2026-05-12 09:43:02 +03:00
Дмитрий 7322c7f33a feat(redesign): Task 7 — useDensity composable (localStorage + rowHeight) 2026-05-12 09:37:43 +03:00
Дмитрий eda13679b4 feat(redesign): Task 6 — useCountUp composable (RAF tween + prefers-reduced-motion) 2026-05-12 09:34:55 +03:00
Дмитрий cdd1b5efdb feat(redesign): Task 5 — useStatusPill composable (14 slugs из db/schema.sql) 2026-05-12 09:29:53 +03:00
Дмитрий ea4570dafe feat(redesign): Task 4 — extend Vuetify theme (12 colors) + global component defaults 2026-05-12 09:27:22 +03:00
Дмитрий b858df569e feat(redesign): Task 3 — motion.css (5 keyframes + reduced-motion wrapper + utilities) 2026-05-12 09:23:50 +03:00
Дмитрий baf27bd02d feat(redesign): Task 2 — typography.css (Inter variable + JetBrains Mono + tnum) 2026-05-12 09:20:13 +03:00
Дмитрий 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