Files
portal/app/resources/js/views/admin/AdminTenantsView.vue
T
Дмитрий cb05657f30 chore(format): prettier --write across 37 .vue/.ts files
Phase 1B audit found 48 files failing `prettier --check`. Auto-apply
via `npx prettier --write resources/js/**/*.{ts,vue,css}` produced
style-only changes:
- consistent quote style
- trailing comma normalization
- spaces around : in v-card style="position: relative" attrs
- explicit ; insertion

No semantic changes. No code-behavior changes. Production-code only;
test files batched separately into `test(frontend):` commit.

Verification:
- npx vitest run → 79/79 files, 614/614 + 3 skipped (no regression).
- npx vue-tsc --noEmit → 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:24:33 +03:00

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