Files
portal/app/resources/js/composables/dealsApiMapper.ts
T
Дмитрий 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

78 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Маппер `ApiDeal` (backend GET /api/deals) → `MockDeal` (UI-формат).
*
* UI-компоненты (DealsView/KanbanView/KanbanCard/DealDetailDrawer) построены
* вокруг `MockDeal`. Чтобы не переписывать их под backend-форму, маппим
* на текущий контракт: вычисляем `receivedMinutesAgo` из ISO-метки,
* подставляем placeholder для отсутствующих manager/project.
*
* `cost` пока 0 — отдельного endpoint'а на сделку нет (cost живёт в
* supplier_lead_costs.cost_rub и снимок берётся в момент webhook'а;
* на UI-листинге не критично, появится отдельным запросом при необходимости).
*/
import type { ApiDeal, ApiDealEvent } from '../api/deals';
import type { LeadStatus } from './leadStatuses';
import type { DealEvent } from './mockDealEvents';
import type { MockDeal } from './mockDeals';
/**
* Маппит backend-событие из ActivityLog в `DealEvent` для timeline.
* Backend `event` slug может быть любым из `deal.*` — clamp на known-types,
* иначе отображаем как `deal.viewed` (generic-icon).
*/
export function mapApiDealEvent(api: ApiDealEvent, now: Date = new Date()): DealEvent {
const knownTypes: DealEvent['type'][] = [
'deal.created',
'deal.status_changed',
'deal.viewed',
'deal.commented',
'deal.assigned',
'deal.balance_charged',
];
const type = knownTypes.includes(api.event as DealEvent['type']) ? (api.event as DealEvent['type']) : 'deal.viewed';
const created = api.created_at ? new Date(api.created_at) : now;
const minutesAgo = Math.max(0, Math.floor((now.getTime() - created.getTime()) / 60000));
let detail = '';
if (api.event === 'deal.status_changed' && api.context) {
const from = (api.context.from as string) ?? '?';
const to = (api.context.to as string) ?? '?';
detail = `${from}${to}`;
} else if (api.event === 'deal.created' && api.context) {
const source = (api.context.source as string) ?? 'неизвестно';
detail = `Лид принят (источник: ${source})`;
} else if (api.context && typeof api.context === 'object') {
// Generic-fallback: JSON-сводка контекста.
detail = Object.entries(api.context)
.map(([k, v]) => `${k}: ${String(v)}`)
.join(', ');
}
return {
id: api.id,
type,
actor: api.actor ? { initials: api.actor.initials, name: api.actor.name } : null,
minutesAgo,
detail,
};
}
export function mapApiDeal(api: ApiDeal, now: Date = new Date()): MockDeal {
const receivedAt = api.received_at ? new Date(api.received_at) : now;
const receivedMinutesAgo = Math.max(0, Math.floor((now.getTime() - receivedAt.getTime()) / 60000));
return {
id: api.id,
name: api.contact_name ?? api.phone,
phone: api.phone,
statusSlug: api.status as LeadStatus['slug'],
project: api.project_name ?? '—',
manager: {
initials: api.manager_initials ?? '—',
name: api.manager_name ?? 'Не назначен',
},
cost: 0,
receivedMinutesAgo,
};
}