2026-05-09 05:33:21 +03:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Карточка тенанта (drill-down из AdminTenantsView).
|
|
|
|
|
|
*
|
2026-05-10 04:38:08 +03:00
|
|
|
|
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): UI-блоки выделены в
|
|
|
|
|
|
* components/admin/tenant-detail/{TenantDetailHeader,TenantDetailTabs}.vue
|
|
|
|
|
|
* + composables/adminTenantDetailFormatters.ts. State (tenant, activeTab,
|
|
|
|
|
|
* impersonationOpen, loadTenant) остаётся в этом view ради
|
|
|
|
|
|
* `defineExpose`-контракта Vitest тестов.
|
|
|
|
|
|
*
|
2026-05-09 05:33:21 +03:00
|
|
|
|
* Маршрут: /admin/tenants/:code (params.code = 'TNT-0042').
|
|
|
|
|
|
*
|
|
|
|
|
|
* 4 KPI вверху + таб-навигация (Финансы / Пользователи / Проекты / Активность).
|
|
|
|
|
|
* На API: GET /api/admin/tenants/{code} → AdminTenantDetail с агрегатами.
|
|
|
|
|
|
*/
|
2026-05-09 14:37:45 +03:00
|
|
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
2026-05-09 05:33:21 +03:00
|
|
|
|
import { useRoute, useRouter } from 'vue-router';
|
2026-05-09 14:37:45 +03:00
|
|
|
|
import { getAdminTenantDetail } from '../../api/admin';
|
|
|
|
|
|
import { extractErrorMessage } from '../../api/client';
|
|
|
|
|
|
import { mapAdminTenantDetail } from '../../composables/adminTenantDetailMapper';
|
|
|
|
|
|
import type { AdminTenantDetail } from '../../composables/mockTenantDetail';
|
2026-05-09 05:33:21 +03:00
|
|
|
|
import ImpersonationDialog from '../../components/admin/ImpersonationDialog.vue';
|
2026-05-10 04:38:08 +03:00
|
|
|
|
import TenantDetailHeader from '../../components/admin/tenant-detail/TenantDetailHeader.vue';
|
|
|
|
|
|
import TenantDetailTabs from '../../components/admin/tenant-detail/TenantDetailTabs.vue';
|
2026-05-09 05:33:21 +03:00
|
|
|
|
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
|
|
|
|
|
|
const code = computed(() => String(route.params.code ?? ''));
|
|
|
|
|
|
|
2026-05-09 14:37:45 +03:00
|
|
|
|
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();
|
2026-05-09 05:33:21 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const ADMIN_USER_ID = 1;
|
|
|
|
|
|
const impersonationOpen = ref(false);
|
|
|
|
|
|
|
|
|
|
|
|
const activeTab = ref<'finance' | 'users' | 'projects' | 'activity'>('finance');
|
|
|
|
|
|
|
|
|
|
|
|
function goBack() {
|
|
|
|
|
|
router.push({ name: 'admin-tenants' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 14:37:45 +03:00
|
|
|
|
defineExpose({ tenant, activeTab, impersonationOpen, loadTenant });
|
2026-05-09 05:33:21 +03:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<v-container v-if="tenant" fluid class="tenant-detail pa-6">
|
2026-05-10 04:38:08 +03:00
|
|
|
|
<TenantDetailHeader :tenant="tenant" @back="goBack" @impersonate="impersonationOpen = true" />
|
|
|
|
|
|
<TenantDetailTabs :tenant="tenant" :active-tab="activeTab" @update:active-tab="activeTab = $event" />
|
2026-05-09 05:33:21 +03:00
|
|
|
|
<ImpersonationDialog v-model="impersonationOpen" :tenant="tenant" :requested-by="ADMIN_USER_ID" />
|
|
|
|
|
|
</v-container>
|
|
|
|
|
|
|
2026-05-09 14:37:45 +03:00
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<v-container v-else-if="notFound" fluid class="pa-6" data-testid="tenant-not-found">
|
2026-05-09 05:33:21 +03:00
|
|
|
|
<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>
|
2026-05-09 14:37:45 +03:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-05-09 05:33:21 +03:00
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.tenant-detail {
|
|
|
|
|
|
max-width: 1440px;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|