cb05657f30
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>
151 lines
5.2 KiB
Vue
151 lines
5.2 KiB
Vue
<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>
|