diff --git a/app/resources/js/api/admin.ts b/app/resources/js/api/admin.ts index 3072decb..3253a836 100644 --- a/app/resources/js/api/admin.ts +++ b/app/resources/js/api/admin.ts @@ -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 | 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 { + const { data } = await apiClient.get( + `/api/admin/tenants/${encodeURIComponent(subdomain)}`, + ); + return data; +} + // === SaaS-admin → Биллинг: aggregates пополнений/списаний === export interface ApiAdminBillingTenant { diff --git a/app/resources/js/composables/adminTenantDetailMapper.ts b/app/resources/js/composables/adminTenantDetailMapper.ts new file mode 100644 index 00000000..5c2f00ef --- /dev/null +++ b/app/resources/js/composables/adminTenantDetailMapper.ts @@ -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 = { + 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 = { + 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; + 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, + }; +} diff --git a/app/resources/js/views/admin/AdminTenantDetailView.vue b/app/resources/js/views/admin/AdminTenantDetailView.vue index f947b029..1e34ead7 100644 --- a/app/resources/js/views/admin/AdminTenantDetailView.vue +++ b/app/resources/js/views/admin/AdminTenantDetailView.vue @@ -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(() => { - const base = MOCK_TENANTS.find((t) => t.code === code.value); - return base ? expandTenantDetail(base) : null; +const tenant = ref(null); +const loading = ref(false); +const fetchError = ref(null); +const notFound = ref(false); + +async function loadTenant(): Promise { + 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 });