Files
portal/app/resources/js/views/admin/AdminTenantsView.vue
T
Дмитрий 30ef61dff8 refactor(frontend): Sprint 4 Phase B/1 — split 3 admin/layout views (audit O-refactor-04 хвост)
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>
2026-05-10 04:38:08 +03:00

156 lines
5.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Админка → Тенанты. Список всех тенантов SaaS с балансами/тарифами/MRR.
*
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): UI-блоки выделены в
* components/admin/tenants/{TenantsStatsHeader,TenantsFilters,TenantsTable}.
* State (filterStatuses/filterTariffs/clearFilters/tenantsState/stats и др.)
* остаётся в этом view ради `defineExpose`-контракта, который Vitest тесты
* используют для прямого доступа.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_admin.html секция #page-tenants.
* По схеме v8.7 §3 (tenants table) + ТЗ §22 (админка).
*
* Click по строке → /admin/tenants/{code} (карточка тенанта).
*/
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
MOCK_STATS,
MOCK_TENANTS,
type AdminTenant,
type TenantStatus,
} from '../../composables/mockTenants';
import { mapApiAdminTenant } from '../../composables/adminTenantsMapper';
import { usePolling } from '../../composables/usePolling';
import * as adminApi from '../../api/admin';
// NB: ImpersonationDialog оставлен sync-import — Vitest test через
// `findComponent({ name: 'ImpersonationDialog' })` + `stubs`, defineAsyncComponent
// ломает identity wrapper'а в test-utils.
import ImpersonationDialog from '../../components/admin/ImpersonationDialog.vue';
import TenantsStatsHeader from '../../components/admin/tenants/TenantsStatsHeader.vue';
import TenantsFilters from '../../components/admin/tenants/TenantsFilters.vue';
import TenantsTable from '../../components/admin/tenants/TenantsTable.vue';
const router = useRouter();
const tenantsState = reactive<AdminTenant[]>(MOCK_TENANTS.map((t) => ({ ...t })));
const stats = reactive({ ...MOCK_STATS });
const loading = ref(false);
const fetchError = ref(false);
async function loadTenants() {
loading.value = true;
fetchError.value = false;
try {
const res = await adminApi.listAdminTenants();
const mapped = res.tenants.map((t) => mapApiAdminTenant(t));
tenantsState.splice(0, tenantsState.length, ...mapped);
stats.total = res.stats.total;
stats.active = res.stats.active;
stats.trial = res.stats.trial;
stats.overdue = res.stats.overdue;
} catch {
fetchError.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadTenants);
usePolling(loadTenants);
function openTenantDetail(t: AdminTenant) {
router.push({ name: 'admin-tenant-detail', params: { code: t.code } });
}
const search = ref('');
const filterStatuses = ref<TenantStatus[]>([]);
const filterTariffs = ref<string[]>([]);
const impersonationOpen = ref(false);
const impersonationTenant = ref<AdminTenant | null>(null);
const availableTariffs = computed(() => Array.from(new Set(tenantsState.map((t) => t.tariff))).sort());
function clearFilters() {
filterStatuses.value = [];
filterTariffs.value = [];
}
const ADMIN_USER_ID = 1;
function openImpersonation(tenant: AdminTenant) {
impersonationTenant.value = tenant;
impersonationOpen.value = true;
}
defineExpose({
filterStatuses,
filterTariffs,
clearFilters,
impersonationOpen,
impersonationTenant,
tenantsState,
stats,
loading,
fetchError,
loadTenants,
});
const filteredTenants = computed<AdminTenant[]>(() => {
const q = search.value.trim().toLowerCase();
const statuses = new Set(filterStatuses.value);
const tariffs = new Set(filterTariffs.value);
return tenantsState.filter((t) => {
if (statuses.size > 0 && !statuses.has(t.status)) return false;
if (tariffs.size > 0 && !tariffs.has(t.tariff)) return false;
if (q) {
const haystack = `${t.name} ${t.inn} ${t.code}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
});
</script>
<template>
<v-container fluid class="admin-tenants pa-6">
<TenantsStatsHeader :stats="stats" :loading="loading" @refresh="loadTenants" />
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
closable
class="mt-3"
data-testid="fetch-error-alert"
>
Backend недоступен показаны mock-данные.
</v-alert>
<TenantsFilters
:search="search"
:filter-statuses="filterStatuses"
:filter-tariffs="filterTariffs"
:available-tariffs="availableTariffs"
@update:search="search = $event"
@update:filter-statuses="filterStatuses = $event"
@update:filter-tariffs="filterTariffs = $event"
@clear="clearFilters"
/>
<TenantsTable :tenants="filteredTenants" @row-click="openTenantDetail" @impersonate="openImpersonation" />
<ImpersonationDialog v-model="impersonationOpen" :tenant="impersonationTenant" :requested-by="ADMIN_USER_ID" />
</v-container>
</template>
<style scoped>
.admin-tenants {
max-width: 1440px;
}
</style>