Files
portal/app/resources/js/views/admin/AdminTenantsView.vue
T
2026-05-23 20:02:39 +03:00

180 lines
6.0 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 { 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 TenantBalanceDialog from '../../components/admin/TenantBalanceDialog.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[]>([]);
const stats = reactive({ total: 0, active: 0, trial: 0, overdue: 0, monthlyRevenueRub: 0 });
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 balanceDialogOpen = ref(false);
const balanceTarget = 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;
}
function openBalanceDialog(tenant: AdminTenant) {
balanceTarget.value = tenant;
balanceDialogOpen.value = true;
}
async function onBalanceSaved(): Promise<void> {
await loadTenants();
}
defineExpose({
filterStatuses,
filterTariffs,
clearFilters,
impersonationOpen,
impersonationTenant,
balanceDialogOpen,
balanceTarget,
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"
>
Не удалось загрузить тенантов. Попробуйте обновить.
</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"
@edit-balance="openBalanceDialog"
/>
<ImpersonationDialog v-model="impersonationOpen" :tenant="impersonationTenant" :requested-by="ADMIN_USER_ID" />
<TenantBalanceDialog
v-if="balanceTarget"
v-model="balanceDialogOpen"
:tenant-id="balanceTarget.id"
:tenant-name="balanceTarget.name"
:current-balance-rub="balanceTarget.balanceRub"
@saved="onBalanceSaved"
/>
</v-container>
</template>
<style scoped>
.admin-tenants {
max-width: 1440px;
}
</style>