Files
portal/app/resources/js/composables/adminTenantDetailMapper.ts
T

182 lines
6.6 KiB
TypeScript

import type {
AdminTenant as ApiAdminTenant,
AdminTenantDetailResponse,
ApiTenantActivityItem,
ApiTenantAliveness,
ApiTenantBalanceTx,
ApiTenantProject,
ApiTenantUser,
} from '../api/admin';
import type {
AdminTenantDetail,
TenantActivityItem,
TenantAliveness,
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',
// users.last_active_at не заполняется никем (замерено 07.08.2026 —
// ноль из одиннадцати), поэтому показываем живой last_login_at.
last_login_at: u.last_login_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 mapAliveness(a: ApiTenantAliveness): TenantAliveness {
return {
state: a.state,
lastLoginAt: a.last_login_at,
daysSinceLogin: a.days_since_login,
lastLoginIp: a.last_login_ip,
lastLoginUserAgent: a.last_login_user_agent,
days: a.days,
visits30d: a.counters.visits_30d,
activeDays30d: a.counters.active_days_30d,
actions30d: a.counters.actions_30d,
failedLogins30d: a.counters.failed_logins_30d,
};
}
export function mapTenantActivityItem(i: ApiTenantActivityItem): TenantActivityItem {
return {
id: i.id,
at: i.at,
kind: i.kind,
event: i.event,
actor: i.actor,
subject: i.subject,
amountRub: i.amount_rub !== null ? parseFloat(i.amount_rub) : null,
bulkCount: i.bulk_count,
};
}
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),
alivenessState: api.aliveness.state,
// расширение 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),
aliveness: mapAliveness(api.aliveness),
activity: api.activity.items.map(mapTenantActivityItem),
activityHasMore: api.activity.has_more,
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,
};
}