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:
@@ -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>
|
||||
|
||||
@@ -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('не активен');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user