cab1f87efd
- 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>
171 lines
6.0 KiB
TypeScript
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,
|
|
};
|
|
}
|