30ef61dff8
3 view'а с >300 строк разделены на shell + sub-components: AdminTenantsView 377→155 (+ TenantsStatsHeader 82 / TenantsFilters 93 / TenantsTable 116). AdminTenantDetailView 436→109 (+ TenantDetailHeader 158 / TenantDetailTabs 176 + adminTenantDetailFormatters 43 composable). AppLayout 466→78 (+ AppSidebar 155 / AppTopbar 269; R0.6 hard-стоп снят явным запросом заказчика 10.05.2026). State (filterStatuses, tenantsState, activeTab, tenant, drawerOpen) остаётся в parent view'ах ради `defineExpose`-контракта Vitest тестов. Sub-components читают Pinia stores напрямую (auth + notifications + reminders) — без prop-drilling. AppTopbar 269 строк <300 — acceptance threshold выдержан (можно дальше split на NotificationsDropdown + UserMenu в отдельном flow, не критично). Регрессия: ESLint 0 + vue-tsc 0 + Vitest 416/416 + build OK 1.17 сек. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
4.3 KiB
Vue
110 lines
4.3 KiB
Vue
<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>
|