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

151 lines
5.2 KiB
Vue
Raw Normal View History

<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>