2026-05-08 19:23:28 +03:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Админка → Тенанты. Список всех тенантов SaaS с балансами/тарифами/MRR.
|
|
|
|
|
|
*
|
2026-06-28 12:05:31 +03:00
|
|
|
|
* Масштаб (28.06.2026): серверная пагинация + серверные фильтры (search/статус/тариф).
|
|
|
|
|
|
* Раньше грузили всех разом и фильтровали в браузере — на 1000 клиентов это не
|
|
|
|
|
|
* «смотрибельно» (поиск/чипы видели только первую страницу). Теперь:
|
|
|
|
|
|
* - страница из `limit/offset` (perPage), счётчик `total` с сервера → v-pagination;
|
|
|
|
|
|
* - поиск (org/subdomain/email ILIKE) — серверный, debounce 400мс;
|
|
|
|
|
|
* - статус (производный trial/overdue/active/suspended) и тариф — серверные multi.
|
|
|
|
|
|
* Бэкенд: AdminTenantsController::index (statuses/tariffs/search/limit/offset/total).
|
2026-05-10 04:38:08 +03:00
|
|
|
|
*
|
2026-06-28 12:05:31 +03:00
|
|
|
|
* State (filterStatuses/filterTariffs/clearFilters/tenantsState/stats и др.) остаётся
|
|
|
|
|
|
* в этом view ради `defineExpose`-контракта Vitest-тестов.
|
2026-05-08 19:23:28 +03:00
|
|
|
|
*
|
2026-06-28 12:05:31 +03:00
|
|
|
|
* Источник дизайна: liderra_v8_handoff/concepts/v8_admin.html секция #page-tenants.
|
2026-05-10 04:38:08 +03:00
|
|
|
|
* Click по строке → /admin/tenants/{code} (карточка тенанта).
|
2026-05-08 19:23:28 +03:00
|
|
|
|
*/
|
2026-06-28 12:05:31 +03:00
|
|
|
|
import { onMounted, reactive, ref, watch } from 'vue';
|
2026-05-09 05:33:21 +03:00
|
|
|
|
import { useRouter } from 'vue-router';
|
2026-05-17 06:39:06 +03:00
|
|
|
|
import { type AdminTenant, type TenantStatus } from '../../composables/mockTenants';
|
2026-05-09 09:19:53 +03:00
|
|
|
|
import { mapApiAdminTenant } from '../../composables/adminTenantsMapper';
|
2026-05-09 10:17:51 +03:00
|
|
|
|
import { usePolling } from '../../composables/usePolling';
|
2026-05-09 09:19:53 +03:00
|
|
|
|
import * as adminApi from '../../api/admin';
|
2026-05-10 04:38:08 +03:00
|
|
|
|
// NB: ImpersonationDialog оставлен sync-import — Vitest test через
|
|
|
|
|
|
// `findComponent({ name: 'ImpersonationDialog' })` + `stubs`, defineAsyncComponent
|
|
|
|
|
|
// ломает identity wrapper'а в test-utils.
|
2026-05-09 04:52:52 +03:00
|
|
|
|
import ImpersonationDialog from '../../components/admin/ImpersonationDialog.vue';
|
2026-05-23 19:50:08 +03:00
|
|
|
|
import TenantBalanceDialog from '../../components/admin/TenantBalanceDialog.vue';
|
2026-05-10 04:38:08 +03:00
|
|
|
|
import TenantsStatsHeader from '../../components/admin/tenants/TenantsStatsHeader.vue';
|
|
|
|
|
|
import TenantsFilters from '../../components/admin/tenants/TenantsFilters.vue';
|
|
|
|
|
|
import TenantsTable from '../../components/admin/tenants/TenantsTable.vue';
|
2026-05-08 19:23:28 +03:00
|
|
|
|
|
2026-05-09 05:33:21 +03:00
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
|
2026-05-17 06:39:06 +03:00
|
|
|
|
const tenantsState = reactive<AdminTenant[]>([]);
|
|
|
|
|
|
const stats = reactive({ total: 0, active: 0, trial: 0, overdue: 0, monthlyRevenueRub: 0 });
|
2026-05-09 09:19:53 +03:00
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const fetchError = ref(false);
|
|
|
|
|
|
|
2026-06-28 12:05:31 +03:00
|
|
|
|
const search = ref('');
|
|
|
|
|
|
const filterStatuses = ref<TenantStatus[]>([]);
|
|
|
|
|
|
const filterTariffs = ref<string[]>([]);
|
|
|
|
|
|
const availableTariffs = ref<string[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
// Серверная пагинация.
|
|
|
|
|
|
const page = ref(1);
|
|
|
|
|
|
const perPage = ref(25);
|
|
|
|
|
|
const total = ref(0);
|
|
|
|
|
|
const totalPages = () => Math.max(1, Math.ceil(total.value / perPage.value));
|
|
|
|
|
|
|
|
|
|
|
|
async function loadTenants(): Promise<void> {
|
2026-05-09 09:19:53 +03:00
|
|
|
|
loading.value = true;
|
|
|
|
|
|
fetchError.value = false;
|
|
|
|
|
|
try {
|
2026-06-28 12:05:31 +03:00
|
|
|
|
const res = await adminApi.listAdminTenants({
|
|
|
|
|
|
search: search.value.trim(),
|
|
|
|
|
|
statuses: filterStatuses.value.join(','),
|
|
|
|
|
|
tariffs: filterTariffs.value.join(','),
|
|
|
|
|
|
limit: perPage.value,
|
|
|
|
|
|
offset: (page.value - 1) * perPage.value,
|
|
|
|
|
|
});
|
2026-05-09 09:19:53 +03:00
|
|
|
|
const mapped = res.tenants.map((t) => mapApiAdminTenant(t));
|
|
|
|
|
|
tenantsState.splice(0, tenantsState.length, ...mapped);
|
2026-06-28 12:05:31 +03:00
|
|
|
|
total.value = res.total;
|
2026-05-09 09:19:53 +03:00
|
|
|
|
stats.total = res.stats.total;
|
|
|
|
|
|
stats.active = res.stats.active;
|
|
|
|
|
|
stats.trial = res.stats.trial;
|
|
|
|
|
|
stats.overdue = res.stats.overdue;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
fetchError.value = true;
|
2026-06-28 12:05:31 +03:00
|
|
|
|
tenantsState.splice(0, tenantsState.length);
|
2026-05-09 09:19:53 +03:00
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 12:05:31 +03:00
|
|
|
|
// Опции тарифов для дропдауна — отдельным запросом (на странице видна только часть
|
|
|
|
|
|
// тенантов, поэтому список тарифов нельзя выводить из загруженного набора).
|
|
|
|
|
|
async function loadTariffOptions(): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const plans = await adminApi.listAdminTariffPlans();
|
|
|
|
|
|
availableTariffs.value = Array.from(new Set(plans.map((p) => p.name))).sort();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// дропдаун останется пустым — не критично для основного списка.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Поиск — debounce 400мс (планшет: печатает → ищет, без кнопки «Найти»).
|
|
|
|
|
|
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
watch(search, () => {
|
|
|
|
|
|
if (searchTimer) clearTimeout(searchTimer);
|
|
|
|
|
|
searchTimer = setTimeout(() => {
|
|
|
|
|
|
page.value = 1;
|
|
|
|
|
|
void loadTenants();
|
|
|
|
|
|
}, 400);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Фильтры — сразу перезагрузка с 1-й страницы.
|
|
|
|
|
|
watch(
|
|
|
|
|
|
[filterStatuses, filterTariffs],
|
|
|
|
|
|
() => {
|
|
|
|
|
|
page.value = 1;
|
|
|
|
|
|
void loadTenants();
|
|
|
|
|
|
},
|
|
|
|
|
|
{ deep: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function goPage(p: number): void {
|
|
|
|
|
|
page.value = p;
|
|
|
|
|
|
void loadTenants();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
void loadTariffOptions();
|
|
|
|
|
|
void loadTenants();
|
|
|
|
|
|
});
|
2026-05-09 10:17:51 +03:00
|
|
|
|
usePolling(loadTenants);
|
2026-05-09 09:19:53 +03:00
|
|
|
|
|
2026-06-28 12:05:31 +03:00
|
|
|
|
function openTenantDetail(t: AdminTenant): void {
|
2026-05-09 05:33:21 +03:00
|
|
|
|
router.push({ name: 'admin-tenant-detail', params: { code: t.code } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 12:05:31 +03:00
|
|
|
|
function clearFilters(): void {
|
|
|
|
|
|
filterStatuses.value = [];
|
|
|
|
|
|
filterTariffs.value = [];
|
|
|
|
|
|
}
|
2026-05-08 19:23:28 +03:00
|
|
|
|
|
2026-05-09 04:52:52 +03:00
|
|
|
|
const impersonationOpen = ref(false);
|
|
|
|
|
|
const impersonationTenant = ref<AdminTenant | null>(null);
|
|
|
|
|
|
|
2026-05-23 19:50:08 +03:00
|
|
|
|
const balanceDialogOpen = ref(false);
|
|
|
|
|
|
const balanceTarget = ref<AdminTenant | null>(null);
|
|
|
|
|
|
|
2026-05-09 04:52:52 +03:00
|
|
|
|
const ADMIN_USER_ID = 1;
|
|
|
|
|
|
|
2026-06-28 12:05:31 +03:00
|
|
|
|
function openImpersonation(tenant: AdminTenant): void {
|
2026-05-09 04:52:52 +03:00
|
|
|
|
impersonationTenant.value = tenant;
|
|
|
|
|
|
impersonationOpen.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 12:05:31 +03:00
|
|
|
|
function openBalanceDialog(tenant: AdminTenant): void {
|
2026-05-23 19:50:08 +03:00
|
|
|
|
balanceTarget.value = tenant;
|
|
|
|
|
|
balanceDialogOpen.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onBalanceSaved(): Promise<void> {
|
|
|
|
|
|
await loadTenants();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 09:19:53 +03:00
|
|
|
|
defineExpose({
|
|
|
|
|
|
filterStatuses,
|
|
|
|
|
|
filterTariffs,
|
|
|
|
|
|
clearFilters,
|
|
|
|
|
|
impersonationOpen,
|
|
|
|
|
|
impersonationTenant,
|
2026-05-23 19:50:08 +03:00
|
|
|
|
balanceDialogOpen,
|
|
|
|
|
|
balanceTarget,
|
2026-05-09 09:19:53 +03:00
|
|
|
|
tenantsState,
|
|
|
|
|
|
stats,
|
|
|
|
|
|
loading,
|
|
|
|
|
|
fetchError,
|
|
|
|
|
|
loadTenants,
|
2026-06-28 12:05:31 +03:00
|
|
|
|
search,
|
|
|
|
|
|
page,
|
|
|
|
|
|
perPage,
|
|
|
|
|
|
total,
|
|
|
|
|
|
availableTariffs,
|
|
|
|
|
|
goPage,
|
2026-05-08 19:23:28 +03:00
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<v-container fluid class="admin-tenants pa-6">
|
2026-05-10 04:38:08 +03:00
|
|
|
|
<TenantsStatsHeader :stats="stats" :loading="loading" @refresh="loadTenants" />
|
2026-05-08 19:23:28 +03:00
|
|
|
|
|
2026-05-09 09:19:53 +03:00
|
|
|
|
<v-alert
|
|
|
|
|
|
v-if="fetchError"
|
|
|
|
|
|
type="warning"
|
|
|
|
|
|
variant="tonal"
|
|
|
|
|
|
density="compact"
|
|
|
|
|
|
closable
|
|
|
|
|
|
class="mt-3"
|
|
|
|
|
|
data-testid="fetch-error-alert"
|
|
|
|
|
|
>
|
2026-05-17 06:39:06 +03:00
|
|
|
|
Не удалось загрузить тенантов. Попробуйте обновить.
|
2026-05-09 09:19:53 +03:00
|
|
|
|
</v-alert>
|
|
|
|
|
|
|
2026-05-10 04:38:08 +03:00
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
2026-05-08 19:23:28 +03:00
|
|
|
|
|
2026-05-23 19:50:08 +03:00
|
|
|
|
<TenantsTable
|
2026-06-28 12:05:31 +03:00
|
|
|
|
:tenants="tenantsState"
|
2026-05-23 19:50:08 +03:00
|
|
|
|
@row-click="openTenantDetail"
|
|
|
|
|
|
@impersonate="openImpersonation"
|
|
|
|
|
|
@edit-balance="openBalanceDialog"
|
|
|
|
|
|
/>
|
2026-05-09 04:52:52 +03:00
|
|
|
|
|
2026-06-28 12:05:31 +03:00
|
|
|
|
<div class="d-flex align-center justify-space-between mt-3 flex-wrap ga-2">
|
|
|
|
|
|
<span class="text-medium-emphasis text-body-2">Всего: {{ total }}</span>
|
|
|
|
|
|
<v-pagination
|
|
|
|
|
|
v-model="page"
|
|
|
|
|
|
:length="totalPages()"
|
|
|
|
|
|
:total-visible="7"
|
|
|
|
|
|
density="compact"
|
|
|
|
|
|
data-testid="tenants-pager"
|
|
|
|
|
|
@update:model-value="goPage"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-09 04:52:52 +03:00
|
|
|
|
<ImpersonationDialog v-model="impersonationOpen" :tenant="impersonationTenant" :requested-by="ADMIN_USER_ID" />
|
2026-05-23 19:50:08 +03:00
|
|
|
|
|
|
|
|
|
|
<TenantBalanceDialog
|
|
|
|
|
|
v-if="balanceTarget"
|
|
|
|
|
|
v-model="balanceDialogOpen"
|
|
|
|
|
|
:tenant-id="balanceTarget.id"
|
|
|
|
|
|
:tenant-name="balanceTarget.name"
|
|
|
|
|
|
:current-balance-rub="balanceTarget.balanceRub"
|
|
|
|
|
|
@saved="onBalanceSaved"
|
|
|
|
|
|
/>
|
2026-05-08 19:23:28 +03:00
|
|
|
|
</v-container>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.admin-tenants {
|
|
|
|
|
|
max-width: 1440px;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|