Files
portal/app/resources/js/views/admin/AdminTenantsView.vue
T
Дмитрий c92d498b57
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(админка): экран Тенанты на серверную пагинацию/поиск/фильтры (масштаб 1000+)
AdminTenantsView грузил всех тенантов разом и фильтровал в браузере — на 1000
клиентов поиск/чипы видели только первую страницу. Теперь страница из limit/offset
+ v-pagination; поиск (ILIKE), статус (производный trial/overdue/active/suspended)
и тариф — серверные multi-фильтры. AdminTenantsController::index: statuses/tariffs
через CASE/whereIn (статус зеркалит adminTenantsMapper.deriveStatus). Опции тарифов —
отдельным запросом listAdminTariffPlans. Демо локально подтверждено.

Тесты: фронт 34/34 (tenants), бэкенд 13/13 (+2 на statuses/tariffs); baseline getJson 13→15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:06:56 +03:00

237 lines
8.3 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.
*
* Масштаб (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).
*
* State (filterStatuses/filterTariffs/clearFilters/tenantsState/stats и др.) остаётся
* в этом view ради `defineExpose`-контракта Vitest-тестов.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_admin.html секция #page-tenants.
* Click по строке → /admin/tenants/{code} (карточка тенанта).
*/
import { onMounted, reactive, ref, watch } 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);
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> {
loading.value = true;
fetchError.value = false;
try {
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,
});
const mapped = res.tenants.map((t) => mapApiAdminTenant(t));
tenantsState.splice(0, tenantsState.length, ...mapped);
total.value = res.total;
stats.total = res.stats.total;
stats.active = res.stats.active;
stats.trial = res.stats.trial;
stats.overdue = res.stats.overdue;
} catch {
fetchError.value = true;
tenantsState.splice(0, tenantsState.length);
} finally {
loading.value = false;
}
}
// Опции тарифов для дропдауна — отдельным запросом (на странице видна только часть
// тенантов, поэтому список тарифов нельзя выводить из загруженного набора).
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();
});
usePolling(loadTenants);
function openTenantDetail(t: AdminTenant): void {
router.push({ name: 'admin-tenant-detail', params: { code: t.code } });
}
function clearFilters(): void {
filterStatuses.value = [];
filterTariffs.value = [];
}
const impersonationOpen = ref(false);
const impersonationTenant = ref<AdminTenant | null>(null);
const balanceDialogOpen = ref(false);
const balanceTarget = ref<AdminTenant | null>(null);
const ADMIN_USER_ID = 1;
function openImpersonation(tenant: AdminTenant): void {
impersonationTenant.value = tenant;
impersonationOpen.value = true;
}
function openBalanceDialog(tenant: AdminTenant): void {
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,
search,
page,
perPage,
total,
availableTariffs,
goPage,
});
</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="tenantsState"
@row-click="openTenantDetail"
@impersonate="openImpersonation"
@edit-balance="openBalanceDialog"
/>
<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>
<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>