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

171 lines
6.0 KiB
TypeScript

import type {
AdminTenant as ApiAdminTenant,
AdminTenantDetailResponse,
ApiTenantActivityEvent,
ApiTenantBalanceTx,
ApiTenantProject,
ApiTenantUser,
} from '../api/admin';
import type {
AdminTenantDetail,
TenantActivityEvent,
TenantBalanceTx,
TenantProject,
TenantUser,
} from './mockTenantDetail';
import type { TenantStatus, TenantTariff } from './mockTenants';
/**
* Маппит AdminTenantDetailResponse (API) → AdminTenantDetail (UI mock-формат).
*
* Поля отсутствующие в API/schema (inn, contact_phone, legal_address, role) —
* подставляются пустыми/'—'/'manager'-default'ом. На MVP UI показывает «—»
* через v-if; в Post-MVP добавим legal_entities-связку.
*
* `code` ← tenant.subdomain (естественный URL slug).
*/
const TARIFF_FALLBACK: TenantTariff = 'Trial';
const KNOWN_TARIFFS: TenantTariff[] = ['Trial', 'Start', 'Команда', 'Pro', 'Enterprise'];
function deriveStatus(api: ApiAdminTenant): TenantStatus {
const balance = parseFloat(api.balance_rub);
const chargeback = parseFloat(api.chargeback_unrecovered_rub);
if (api.is_trial) return 'trial';
if (api.status === 'suspended') return 'suspended';
if (chargeback > 0 || balance < 0) return 'overdue';
if (api.status === 'active') return 'active';
return 'suspended';
}
function statusText(status: TenantStatus): string {
const map: Record<TenantStatus, string> = {
active: 'Активен',
trial: 'Trial',
overdue: 'Просрочка',
suspended: 'Приостановлен',
};
return map[status];
}
function deriveTariff(name: string | null, isTrial: boolean): TenantTariff {
if (isTrial) return 'Trial';
if (name === null) return TARIFF_FALLBACK;
const match = KNOWN_TARIFFS.find((t) => t === name);
return match ?? TARIFF_FALLBACK;
}
function activitySinceText(lastActivityIso: string | null, now: Date): string {
if (lastActivityIso === null) return 'не активен';
const minutesAgo = Math.floor((now.getTime() - new Date(lastActivityIso).getTime()) / 60_000);
if (minutesAgo < 1) return 'только что';
if (minutesAgo < 60) return `${minutesAgo} мин назад`;
if (minutesAgo < 60 * 24) return `${Math.floor(minutesAgo / 60)} ч назад`;
return `${Math.floor(minutesAgo / (60 * 24))} д назад`;
}
function mapUser(u: ApiTenantUser): TenantUser {
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim();
return {
id: u.id,
email: u.email,
fullName: fullName !== '' ? fullName : u.email,
// role в schema users отсутствует — на MVP единая роль 'manager'.
// Расширение в legal_entities/user_roles вынесено в Post-MVP.
role: 'manager',
last_active_at: u.last_active_at ?? '',
is_active: u.is_active,
};
}
function mapProject(p: ApiTenantProject): TenantProject {
return {
id: p.id,
name: p.name,
slug: p.tag ?? '',
suppliers: p.suppliers_count,
leadsToday: p.leads_today,
desiredToday: p.daily_limit_target,
is_active: p.is_active,
};
}
const TX_TYPE_MAP: Record<string, TenantBalanceTx['type']> = {
topup: 'topup',
lead_charge: 'lead_charge',
refund: 'refund',
manual_adjustment: 'manual_adjustment',
// schema-only типы → ближайший UI-эквивалент.
chargeback_writedown: 'manual_adjustment',
chargeback_repayment: 'topup',
trial_bonus: 'topup',
historical_import: 'manual_adjustment',
};
function mapBalanceTx(tx: ApiTenantBalanceTx): TenantBalanceTx {
return {
id: `TX-${tx.id}`,
type: TX_TYPE_MAP[tx.type] ?? 'manual_adjustment',
amount: parseFloat(tx.amount_rub),
description: tx.description ?? '',
created_at: tx.created_at,
};
}
function mapActivity(ev: ApiTenantActivityEvent): TenantActivityEvent {
const summary = buildActivitySummary(ev);
return {
id: ev.id,
event: ev.event,
actor: ev.actor_email ?? 'system',
summary,
created_at: ev.created_at,
};
}
function buildActivitySummary(ev: ApiTenantActivityEvent): string {
if (ev.context && typeof ev.context === 'object') {
const ctx = ev.context as Record<string, unknown>;
if (typeof ctx.from === 'string' && typeof ctx.to === 'string') {
return `Сделка #${ev.deal_id}: ${ctx.from}${ctx.to}`;
}
}
if (ev.deal_id > 0) {
return `Сделка #${ev.deal_id}`;
}
return ev.event;
}
export function mapAdminTenantDetail(api: AdminTenantDetailResponse, now: Date = new Date()): AdminTenantDetail {
const status = deriveStatus(api.tenant);
const balanceRub = parseFloat(api.tenant.balance_rub);
return {
// base AdminTenant
id: api.tenant.id,
code: api.tenant.subdomain,
name: api.tenant.organization_name,
inn: '',
status,
statusText: statusText(status),
tariff: deriveTariff(api.tenant.tariff_name, api.tenant.is_trial),
balanceRub,
todayDesired: api.tenant.desired_daily_numbers ?? 0,
todayActual: api.metrics.leads_today,
mrrRub: api.tenant.mrr_rub !== null ? parseFloat(api.tenant.mrr_rub) : null,
activitySince: activitySinceText(api.tenant.last_activity_at, now),
// расширение AdminTenantDetail
contact_email: api.tenant.contact_email,
contact_phone: '',
legal_address: '',
created_at: api.tenant.created_at ?? '',
users: api.users.map(mapUser),
projects: api.projects.map(mapProject),
balanceHistory: api.balance_history.map(mapBalanceTx),
activity: api.activity.map(mapActivity),
leadsThisMonth: api.metrics.leads_this_month,
leadsThisWeek: api.metrics.leads_this_week,
avgLeadCost: api.metrics.avg_lead_cost_rub ?? 0,
runwayDays: api.metrics.runway_days ?? 0,
};
}