Files
portal/app/resources/js/views/admin/AdminTenantDetailView.vue
T

110 lines
4.3 KiB
Vue
Raw Normal View History

<script setup lang="ts">
/**
* Карточка тенанта (drill-down из AdminTenantsView).
*
* 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 тестов.
*
* Маршрут: /admin/tenants/:code (params.code = 'TNT-0042').
*
* 4 KPI вверху + таб-навигация (Финансы / Пользователи / Проекты / Активность).
* На API: GET /api/admin/tenants/{code} → AdminTenantDetail с агрегатами.
*/
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
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';
import TenantDetailHeader from '../../components/admin/tenant-detail/TenantDetailHeader.vue';
import TenantDetailTabs from '../../components/admin/tenant-detail/TenantDetailTabs.vue';
const route = useRoute();
const router = useRouter();
const code = computed(() => String(route.params.code ?? ''));
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;
const impersonationOpen = ref(false);
const activeTab = ref<'finance' | 'users' | 'projects' | 'activity'>('finance');
function goBack() {
router.push({ name: 'admin-tenants' });
}
defineExpose({ tenant, activeTab, impersonationOpen, loadTenant });
</script>
<template>
<v-container v-if="tenant" fluid class="tenant-detail pa-6">
<TenantDetailHeader :tenant="tenant" @back="goBack" @impersonate="impersonationOpen = true" />
<TenantDetailTabs :tenant="tenant" :active-tab="activeTab" @update:active-tab="activeTab = $event" />
<ImpersonationDialog v-model="impersonationOpen" :tenant="tenant" :requested-by="ADMIN_USER_ID" />
</v-container>
<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">
<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>
<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>
.tenant-detail {
max-width: 1440px;
}
</style>