cba76c5d18
Закрывает 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>
78 lines
3.4 KiB
TypeScript
78 lines
3.4 KiB
TypeScript
/**
|
||
* Маппер `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,
|
||
};
|
||
}
|