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>
This commit is contained in:
Дмитрий
2026-05-09 14:37:45 +03:00
parent f9d8926945
commit cab1f87efd
5 changed files with 794 additions and 62 deletions
+66
View File
@@ -140,6 +140,72 @@ export async function listAdminTenants(params: ListAdminTenantsParams = {}): Pro
return data;
}
// === SaaS-admin → Тенанты → детали (для AdminTenantDetailView) ===
export interface ApiTenantUser {
id: number;
email: string;
first_name: string | null;
last_name: string | null;
is_active: boolean;
totp_enabled: boolean;
last_active_at: string | null;
last_login_at: string | null;
}
export interface ApiTenantProject {
id: number;
name: string;
tag: string | null;
is_active: boolean;
daily_limit_target: number;
suppliers_count: number;
leads_today: number;
}
export interface ApiTenantBalanceTx {
id: number;
type: string;
amount_rub: string;
amount_leads: number;
balance_rub_after: string | null;
description: string | null;
created_at: string;
}
export interface ApiTenantActivityEvent {
id: number;
event: string;
deal_id: number;
actor_email: string | null;
context: Record<string, unknown> | null;
created_at: string;
}
export interface ApiTenantMetrics {
leads_today: number;
leads_this_week: number;
leads_this_month: number;
avg_lead_cost_rub: number | null;
runway_days: number | null;
}
export interface AdminTenantDetailResponse {
tenant: AdminTenant;
users: ApiTenantUser[];
projects: ApiTenantProject[];
balance_history: ApiTenantBalanceTx[];
activity: ApiTenantActivityEvent[];
metrics: ApiTenantMetrics;
}
export async function getAdminTenantDetail(subdomain: string): Promise<AdminTenantDetailResponse> {
const { data } = await apiClient.get<AdminTenantDetailResponse>(
`/api/admin/tenants/${encodeURIComponent(subdomain)}`,
);
return data;
}
// === SaaS-admin → Биллинг: aggregates пополнений/списаний ===
export interface ApiAdminBillingTenant {
@@ -0,0 +1,170 @@
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,
};
}
@@ -11,10 +11,12 @@
* На API: GET /api/admin/tenants/{code} → AdminTenantDetail с агрегатами.
* На MVP — резолвим из MOCK_TENANTS + expandTenantDetail (mock-данные).
*/
import { computed, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { MOCK_TENANTS } from '../../composables/mockTenants';
import { expandTenantDetail, type AdminTenantDetail } from '../../composables/mockTenantDetail';
import { getAdminTenantDetail } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
import { mapAdminTenantDetail } from '../../composables/adminTenantDetailMapper';
import type { AdminTenantDetail } from '../../composables/mockTenantDetail';
import ImpersonationDialog from '../../components/admin/ImpersonationDialog.vue';
const route = useRoute();
@@ -22,9 +24,38 @@ const router = useRouter();
const code = computed(() => String(route.params.code ?? ''));
const tenant = computed<AdminTenantDetail | null>(() => {
const base = MOCK_TENANTS.find((t) => t.code === code.value);
return base ? expandTenantDetail(base) : null;
const tenant = ref<AdminTenantDetail | null>(null);
const loading = ref(false);
const fetchError = ref<string | null>(null);
const notFound = ref(false);
async function loadTenant(): Promise<void> {
if (code.value === '') return;
loading.value = true;
fetchError.value = null;
notFound.value = false;
try {
const data = await getAdminTenantDetail(code.value);
tenant.value = mapAdminTenantDetail(data);
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status;
if (status === 404) {
notFound.value = true;
tenant.value = null;
} else {
fetchError.value = extractErrorMessage(e);
}
} finally {
loading.value = false;
}
}
onMounted(() => {
void loadTenant();
});
watch(code, () => {
void loadTenant();
});
const ADMIN_USER_ID = 1;
@@ -75,7 +106,7 @@ function roleLabel(r: string): string {
return { admin: 'Администратор', manager: 'Менеджер', reader: 'Читатель' }[r] ?? r;
}
defineExpose({ tenant, activeTab, impersonationOpen });
defineExpose({ tenant, activeTab, impersonationOpen, loadTenant });
</script>
<template>
@@ -91,17 +122,25 @@ defineExpose({ tenant, activeTab, impersonationOpen });
<h1 class="text-h4 mb-1 page-title">{{ tenant.name }}</h1>
<div class="page-meta text-body-2 text-medium-emphasis">
<span>{{ tenant.code }}</span>
<span class="sep">·</span>
<span>ИНН {{ tenant.inn }}</span>
<span class="sep">·</span>
<span>{{ tenant.legal_address }}</span>
<template v-if="tenant.inn">
<span class="sep">·</span>
<span>ИНН {{ tenant.inn }}</span>
</template>
<template v-if="tenant.legal_address">
<span class="sep">·</span>
<span>{{ tenant.legal_address }}</span>
</template>
</div>
<div class="page-meta text-body-2 text-medium-emphasis mt-1">
<span>{{ tenant.contact_email }}</span>
<span class="sep">·</span>
<span>{{ tenant.contact_phone }}</span>
<span class="sep">·</span>
<span>создан {{ tenant.created_at }}</span>
<template v-if="tenant.contact_phone">
<span class="sep">·</span>
<span>{{ tenant.contact_phone }}</span>
</template>
<template v-if="tenant.created_at">
<span class="sep">·</span>
<span>создан {{ tenant.created_at }}</span>
</template>
</div>
</div>
<div class="head-actions">
@@ -275,13 +314,28 @@ defineExpose({ tenant, activeTab, impersonationOpen });
<ImpersonationDialog v-model="impersonationOpen" :tenant="tenant" :requested-by="ADMIN_USER_ID" />
</v-container>
<!-- Loading state -->
<v-container v-else-if="loading" fluid class="pa-6" data-testid="tenant-loading">
<v-progress-circular indeterminate color="primary" />
<span class="ml-3 text-medium-emphasis">Загрузка</span>
</v-container>
<!-- 404 fallback внутри admin-layout -->
<v-container v-else fluid class="pa-6" data-testid="tenant-not-found">
<v-container v-else-if="notFound" fluid class="pa-6" data-testid="tenant-not-found">
<v-alert type="error" variant="tonal" class="mb-4">
Тенант с кодом <strong>{{ code }}</strong> не найден.
</v-alert>
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="goBack">К списку тенантов</v-btn>
</v-container>
<!-- Backend недоступен -->
<v-container v-else-if="fetchError" fluid class="pa-6" data-testid="tenant-fetch-error">
<v-alert type="warning" variant="tonal" class="mb-4"> Не удалось загрузить тенанта: {{ fetchError }} </v-alert>
<div class="d-flex ga-2">
<v-btn variant="outlined" prepend-icon="mdi-refresh" @click="loadTenant">Повторить</v-btn>
<v-btn variant="text" prepend-icon="mdi-arrow-left" @click="goBack">К списку</v-btn>
</div>
</v-container>
</template>
<style scoped>
+214 -46
View File
@@ -1,10 +1,126 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import AdminTenantDetailView from '../../resources/js/views/admin/AdminTenantDetailView.vue';
import type { AdminTenantDetailResponse } from '../../resources/js/api/admin';
const buildRouter = (code: string) => {
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return {
...orig,
getAdminTenantDetail: vi.fn(),
};
});
const adminApi = await import('../../resources/js/api/admin');
beforeEach(() => {
vi.clearAllMocks();
});
function makeApiDetail(overrides: Partial<AdminTenantDetailResponse> = {}): AdminTenantDetailResponse {
return {
tenant: {
id: 42,
subdomain: 'okna-moscow',
organization_name: 'Окна Москва ООО',
contact_email: 'admin@okna-moscow.ru',
status: 'active',
balance_rub: '14250.00',
balance_leads: 5,
is_trial: false,
last_activity_at: new Date(Date.now() - 30 * 60_000).toISOString(),
tariff_id: 2,
tariff_name: 'Команда',
mrr_rub: '990.00',
desired_daily_numbers: 12,
chargeback_unrecovered_rub: '0.00',
created_at: '2025-09-12T00:00:00+00:00',
},
users: [
{
id: 1,
email: 'admin@okna-moscow.ru',
first_name: 'Алексей',
last_name: 'Петров',
is_active: true,
totp_enabled: true,
last_active_at: new Date().toISOString(),
last_login_at: new Date().toISOString(),
},
{
id: 2,
email: 'ivan@okna-moscow.ru',
first_name: 'Иван',
last_name: 'П.',
is_active: true,
totp_enabled: false,
last_active_at: new Date().toISOString(),
last_login_at: new Date().toISOString(),
},
],
projects: [
{
id: 1,
name: 'Натяжные потолки',
tag: 'natyazhnye-potolki',
is_active: true,
daily_limit_target: 8,
suppliers_count: 3,
leads_today: 6,
},
],
balance_history: [
{
id: 347,
type: 'topup',
amount_rub: '5000.00',
amount_leads: 0,
balance_rub_after: '14250.00',
description: 'Пополнение ЮKassa',
created_at: '2026-05-09T09:14:00+00:00',
},
{
id: 346,
type: 'lead_charge',
amount_rub: '-1850.00',
amount_leads: -1,
balance_rub_after: '9250.00',
description: 'Лид Анна Соколова',
created_at: '2026-05-09T08:42:00+00:00',
},
],
activity: [
{
id: 1,
event: 'webhook.received',
deal_id: 4471,
actor_email: null,
context: null,
created_at: '2026-05-09T08:42:00+00:00',
},
{
id: 2,
event: 'deal.status_changed',
deal_id: 4470,
actor_email: 'ivan@okna-moscow.ru',
context: { from: 'viewed', to: 'worked' },
created_at: '2026-05-09T07:18:00+00:00',
},
],
metrics: {
leads_today: 11,
leads_this_week: 42,
leads_this_month: 187,
avg_lead_cost_rub: 1980,
runway_days: 50,
},
...overrides,
};
}
const buildRouter = (subdomain: string) => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
@@ -16,61 +132,75 @@ const buildRouter = (code: string) => {
},
],
});
return router.push({ name: 'admin-tenant-detail', params: { code } }).then(() => router);
return router.push({ name: 'admin-tenant-detail', params: { code: subdomain } }).then(() => router);
};
const mountDetail = async (code: string) => {
const router = await buildRouter(code);
const mountDetail = async (subdomain: string) => {
const router = await buildRouter(subdomain);
await router.isReady();
return mount(AdminTenantDetailView, {
const wrapper = mount(AdminTenantDetailView, {
global: {
plugins: [createVuetify(), router],
stubs: { ImpersonationDialog: true },
},
});
await flushPromises();
return wrapper;
};
describe('AdminTenantDetailView.vue', () => {
it('рендерит карточку Окна Москва ООО (TNT-0042) — name + ИНН + tariff', async () => {
const wrapper = await mountDetail('TNT-0042');
describe('AdminTenantDetailView.vue (API integration)', () => {
it('вызывает getAdminTenantDetail с subdomain из route', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
await mountDetail('okna-moscow');
expect(adminApi.getAdminTenantDetail).toHaveBeenCalledWith('okna-moscow');
});
it('рендерит карточку с organization_name + tariff_name', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
const text = wrapper.text();
expect(text).toContain('Окна Москва ООО');
expect(text).toContain('TNT-0042');
expect(text).toContain('ИНН 7724444444');
expect(text).toContain('okna-moscow'); // code = subdomain
expect(text).toContain('Команда');
});
it('показывает 4 KPI cards (Баланс / Тариф / Лиды / Средняя цена)', async () => {
const wrapper = await mountDetail('TNT-0042');
it('показывает 4 KPI cards', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
['kpi-balance', 'kpi-mrr', 'kpi-leads', 'kpi-avg-cost'].forEach((id) => {
expect(wrapper.find(`[data-testid="${id}"]`).exists()).toBe(true);
});
});
it('по умолчанию активен таб «Финансы» (история операций)', async () => {
const wrapper = await mountDetail('TNT-0042');
expect(wrapper.find('[data-testid="pane-finance"]').exists()).toBe(true);
const text = wrapper.text();
expect(text).toContain('История операций');
// Хотя бы один tx из mock — пополнение ЮKassa
expect(text).toContain('Пополнение ЮKassa');
it('KPI Лиды показывает leads_today / desired_daily_numbers', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
const leadsCard = wrapper.find('[data-testid="kpi-leads"]');
expect(leadsCard.text()).toContain('11 / 12');
});
it('переключение таба Пользователи показывает таблицу users', async () => {
const wrapper = await mountDetail('TNT-0042');
it('по умолчанию активен таб «Финансы» с balance_history', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
expect(wrapper.find('[data-testid="pane-finance"]').exists()).toBe(true);
expect(wrapper.text()).toContain('Пополнение ЮKassa');
});
it('переключение таба Пользователи показывает users', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
const vm = wrapper.vm as unknown as { activeTab: string };
vm.activeTab = 'users';
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="pane-users"]').exists()).toBe(true);
const text = wrapper.text();
expect(text).toContain('admin@tnt-0042.ru');
expect(text).toContain('Иван П.');
expect(text).toContain('Администратор');
expect(text).toContain('Менеджер');
expect(text).toContain('admin@okna-moscow.ru');
expect(text).toContain('Алексей Петров');
});
it('переключение таба Проекты показывает таблицу projects', async () => {
const wrapper = await mountDetail('TNT-0042');
it('переключение таба Проекты показывает projects', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
const vm = wrapper.vm as unknown as { activeTab: string };
vm.activeTab = 'projects';
await wrapper.vm.$nextTick();
@@ -80,23 +210,24 @@ describe('AdminTenantDetailView.vue', () => {
expect(text).toContain('natyazhnye-potolki');
});
it('переключение таба Активность показывает activity-list с event-кодами', async () => {
const wrapper = await mountDetail('TNT-0042');
it('переключение таба Активность показывает event-коды + actor + summary', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
const vm = wrapper.vm as unknown as { activeTab: string };
vm.activeTab = 'activity';
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="pane-activity"]').exists()).toBe(true);
const text = wrapper.text();
expect(text).toContain('webhook.received');
expect(text).toContain('deal.created');
expect(text).toContain('login_success');
expect(text).toContain('deal.status_changed');
expect(text).toContain('ivan@okna-moscow.ru'); // actor_email
expect(text).toContain('viewed → worked'); // summary из context
});
it('кнопка «Войти как клиент» open impersonationDialog', async () => {
const wrapper = await mountDetail('TNT-0042');
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
const vm = wrapper.vm as unknown as { impersonationOpen: boolean };
// ImpersonationOpen внутри script setup, но мы не expose'или — просто
// проверим что click меняет состояние через emitted ImpersonationDialog stub.
const btn = wrapper.find('[data-testid="impersonate-btn"]');
expect(btn.exists()).toBe(true);
await btn.trigger('click');
@@ -105,24 +236,61 @@ describe('AdminTenantDetailView.vue', () => {
});
it('suspended-тенант имеет disabled impersonate-кнопку', async () => {
const wrapper = await mountDetail('TNT-0105'); // Оконные системы РФ — suspended
const data = makeApiDetail({
tenant: { ...makeApiDetail().tenant, status: 'suspended' },
});
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(data);
const wrapper = await mountDetail('okna-moscow');
const btn = wrapper.find('[data-testid="impersonate-btn"]');
expect(btn.exists()).toBe(true);
expect(btn.attributes('disabled')).toBeDefined();
});
it('неизвестный code → показывает 404 fallback', async () => {
const wrapper = await mountDetail('TNT-9999');
it('404 от API → fallback с tenant-not-found', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockRejectedValue({
isAxiosError: true,
response: { status: 404, data: { message: 'Тенант не найден.' } },
});
const wrapper = await mountDetail('unknown-tenant');
expect(wrapper.find('[data-testid="tenant-not-found"]').exists()).toBe(true);
expect(wrapper.text()).toContain('TNT-9999');
expect(wrapper.text()).toContain('unknown-tenant');
expect(wrapper.text()).toContain('не найден');
});
it('overdue-тенант (TNT-0092) — баланс с error-color, runway 0', async () => {
const wrapper = await mountDetail('TNT-0092');
const text = wrapper.text();
expect(text).toContain('Двери Премиум');
expect(text).toContain('Просрочка 3 дня');
expect(text).toMatch(/runway\s+~0\s+дн/);
it('500 от API → fetch-error с кнопкой Повторить', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockRejectedValue({
isAxiosError: true,
response: { status: 500, data: { message: 'Backend сломан' } },
});
const wrapper = await mountDetail('okna-moscow');
expect(wrapper.find('[data-testid="tenant-fetch-error"]').exists()).toBe(true);
expect(wrapper.text()).toContain('Backend сломан');
});
it('overdue (negative balance) — статус Просрочка', async () => {
const data = makeApiDetail({
tenant: {
...makeApiDetail().tenant,
balance_rub: '-500.00',
chargeback_unrecovered_rub: '500.00',
},
});
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(data);
const wrapper = await mountDetail('okna-moscow');
expect(wrapper.text()).toContain('Просрочка');
});
it('trial-тенант → tariff=Trial + mrr_rub скрыт (mrrRub null)', async () => {
const data = makeApiDetail({
tenant: {
...makeApiDetail().tenant,
is_trial: true,
mrr_rub: null,
},
});
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(data);
const wrapper = await mountDetail('okna-moscow');
expect(wrapper.text()).toContain('Trial');
expect(wrapper.text()).toContain('без оплаты');
});
});
@@ -0,0 +1,274 @@
import { describe, it, expect } from 'vitest';
import { mapAdminTenantDetail } from '../../resources/js/composables/adminTenantDetailMapper';
import type { AdminTenantDetailResponse } from '../../resources/js/api/admin';
function makeApi(overrides: Partial<AdminTenantDetailResponse> = {}): AdminTenantDetailResponse {
return {
tenant: {
id: 1,
subdomain: 'test-co',
organization_name: 'Test ООО',
contact_email: 'admin@test-co.ru',
status: 'active',
balance_rub: '10000.00',
balance_leads: 5,
is_trial: false,
last_activity_at: null,
tariff_id: 1,
tariff_name: 'Команда',
mrr_rub: '990.00',
desired_daily_numbers: 10,
chargeback_unrecovered_rub: '0.00',
created_at: '2025-01-01T00:00:00+00:00',
},
users: [],
projects: [],
balance_history: [],
activity: [],
metrics: {
leads_today: 5,
leads_this_week: 30,
leads_this_month: 120,
avg_lead_cost_rub: 250,
runway_days: 40,
},
...overrides,
};
}
describe('adminTenantDetailMapper', () => {
it('маппит code = subdomain', () => {
const ui = mapAdminTenantDetail(makeApi());
expect(ui.code).toBe('test-co');
});
it('маппит name = organization_name', () => {
expect(mapAdminTenantDetail(makeApi()).name).toBe('Test ООО');
});
it('inn/contact_phone/legal_address пустые (нет в schema)', () => {
const ui = mapAdminTenantDetail(makeApi());
expect(ui.inn).toBe('');
expect(ui.contact_phone).toBe('');
expect(ui.legal_address).toBe('');
});
it('balanceRub парсится из string в number', () => {
expect(mapAdminTenantDetail(makeApi()).balanceRub).toBe(10000);
});
it('mrrRub: парсится в число для не-trial; null для trial', () => {
expect(mapAdminTenantDetail(makeApi()).mrrRub).toBe(990);
const trial = mapAdminTenantDetail(makeApi({ tenant: { ...makeApi().tenant, is_trial: true, mrr_rub: null } }));
expect(trial.mrrRub).toBeNull();
});
it('status derive: trial при is_trial=true', () => {
const ui = mapAdminTenantDetail(makeApi({ tenant: { ...makeApi().tenant, is_trial: true, mrr_rub: null } }));
expect(ui.status).toBe('trial');
expect(ui.statusText).toBe('Trial');
});
it('status derive: overdue при balance<0 или chargeback>0', () => {
const a = mapAdminTenantDetail(makeApi({ tenant: { ...makeApi().tenant, balance_rub: '-100.00' } }));
expect(a.status).toBe('overdue');
const b = mapAdminTenantDetail(
makeApi({ tenant: { ...makeApi().tenant, chargeback_unrecovered_rub: '500.00' } }),
);
expect(b.status).toBe('overdue');
});
it('status derive: suspended → suspended', () => {
const ui = mapAdminTenantDetail(makeApi({ tenant: { ...makeApi().tenant, status: 'suspended' } }));
expect(ui.status).toBe('suspended');
});
it('tariff: из tariff_name (или Trial если is_trial)', () => {
expect(mapAdminTenantDetail(makeApi()).tariff).toBe('Команда');
const trial = mapAdminTenantDetail(makeApi({ tenant: { ...makeApi().tenant, is_trial: true, mrr_rub: null } }));
expect(trial.tariff).toBe('Trial');
});
it('tariff fallback Trial если name не из known list', () => {
const ui = mapAdminTenantDetail(makeApi({ tenant: { ...makeApi().tenant, tariff_name: 'NotInList' } }));
expect(ui.tariff).toBe('Trial');
});
it('todayActual = metrics.leads_today; todayDesired = desired_daily_numbers', () => {
const ui = mapAdminTenantDetail(makeApi());
expect(ui.todayActual).toBe(5);
expect(ui.todayDesired).toBe(10);
});
it('users mapping: fullName из first+last; fallback на email', () => {
const ui = mapAdminTenantDetail(
makeApi({
users: [
{
id: 1,
email: 'a@b.ru',
first_name: 'Иван',
last_name: 'Петров',
is_active: true,
totp_enabled: false,
last_active_at: null,
last_login_at: null,
},
{
id: 2,
email: 'c@d.ru',
first_name: null,
last_name: null,
is_active: false,
totp_enabled: false,
last_active_at: null,
last_login_at: null,
},
],
}),
);
expect(ui.users[0]!.fullName).toBe('Иван Петров');
expect(ui.users[1]!.fullName).toBe('c@d.ru');
expect(ui.users[0]!.role).toBe('manager');
});
it('projects mapping: slug=tag, suppliers=suppliers_count, leadsToday/desiredToday', () => {
const ui = mapAdminTenantDetail(
makeApi({
projects: [
{
id: 1,
name: 'P1',
tag: 'p1-slug',
is_active: true,
daily_limit_target: 8,
suppliers_count: 3,
leads_today: 5,
},
],
}),
);
expect(ui.projects[0]!.slug).toBe('p1-slug');
expect(ui.projects[0]!.suppliers).toBe(3);
expect(ui.projects[0]!.leadsToday).toBe(5);
expect(ui.projects[0]!.desiredToday).toBe(8);
});
it('balanceHistory: id префиксуется TX-, amount парсится в number', () => {
const ui = mapAdminTenantDetail(
makeApi({
balance_history: [
{
id: 347,
type: 'topup',
amount_rub: '5000.00',
amount_leads: 0,
balance_rub_after: '15000.00',
description: 'X',
created_at: '2026-01-01T00:00:00Z',
},
],
}),
);
expect(ui.balanceHistory[0]!.id).toBe('TX-347');
expect(ui.balanceHistory[0]!.amount).toBe(5000);
expect(ui.balanceHistory[0]!.type).toBe('topup');
});
it('balanceHistory: chargeback_writedown → manual_adjustment, chargeback_repayment → topup', () => {
const ui = mapAdminTenantDetail(
makeApi({
balance_history: [
{
id: 1,
type: 'chargeback_writedown',
amount_rub: '-500',
amount_leads: 0,
balance_rub_after: null,
description: '',
created_at: '2026-01-01T00:00:00Z',
},
{
id: 2,
type: 'chargeback_repayment',
amount_rub: '500',
amount_leads: 0,
balance_rub_after: null,
description: '',
created_at: '2026-01-01T00:00:00Z',
},
],
}),
);
expect(ui.balanceHistory[0]!.type).toBe('manual_adjustment');
expect(ui.balanceHistory[1]!.type).toBe('topup');
});
it('activity: actor=actor_email; null → system', () => {
const ui = mapAdminTenantDetail(
makeApi({
activity: [
{
id: 1,
event: 'webhook.received',
deal_id: 100,
actor_email: null,
context: null,
created_at: '2026-01-01T00:00:00Z',
},
{
id: 2,
event: 'deal.status_changed',
deal_id: 200,
actor_email: 'user@test.io',
context: { from: 'new', to: 'worked' },
created_at: '2026-01-01T00:00:00Z',
},
],
}),
);
expect(ui.activity[0]!.actor).toBe('system');
expect(ui.activity[1]!.actor).toBe('user@test.io');
expect(ui.activity[1]!.summary).toBe('Сделка #200: new → worked');
});
it('metrics: leadsThisMonth/Week/avgLeadCost/runwayDays', () => {
const ui = mapAdminTenantDetail(makeApi());
expect(ui.leadsThisMonth).toBe(120);
expect(ui.leadsThisWeek).toBe(30);
expect(ui.avgLeadCost).toBe(250);
expect(ui.runwayDays).toBe(40);
});
it('avgLeadCost / runwayDays default 0 если null', () => {
const ui = mapAdminTenantDetail(
makeApi({
metrics: {
leads_today: 0,
leads_this_week: 0,
leads_this_month: 0,
avg_lead_cost_rub: null,
runway_days: null,
},
}),
);
expect(ui.avgLeadCost).toBe(0);
expect(ui.runwayDays).toBe(0);
});
it('activitySince: «N мин назад» / «не активен» если null', () => {
const now = new Date('2026-05-09T12:00:00Z');
const ui1 = mapAdminTenantDetail(
makeApi({
tenant: {
...makeApi().tenant,
last_activity_at: '2026-05-09T11:30:00Z',
},
}),
now,
);
expect(ui1.activitySince).toBe('30 мин назад');
const ui2 = mapAdminTenantDetail(makeApi(), now);
expect(ui2.activitySince).toBe('не активен');
});
});