Files
portal/app/resources/js/views/admin/AdminTenantsView.vue
T
Дмитрий 01c20e7b6c phase2(polling): usePolling composable 30 сек + Page Visibility pause
Закрывает последний unblocked production-TODO «Polling/SSE для real-time».
Manual reload-btn остаётся как fast-path; polling — фоновый автообновитель.

Composable (composables/usePolling.ts):
- usePolling(loader, {intervalMs=30_000, enabled=true}).
- Page Visibility API: при document.hidden=true interval останавливается;
  при visibilitychange с возвратом hidden=false — restart + немедленный
  loader() (не ждать следующего interval'а).
- Cleanup на onBeforeUnmount: clearInterval + removeEventListener.
- enabled=false — composable не стартует (feature-flag).

Integration:
- DealsView + KanbanView → loadDeals.
- AdminTenantsView → loadTenants.
- AdminBillingView → loadBilling.
- AdminIncidentsView → loadIncidents.

Vitest +6 (usePolling.spec.ts) с vi.useFakeTimers:
- Вызов каждые intervalMs / default 30 сек / skip при document.hidden /
  cleanup на unmount / enabled=false → no-op / visibilitychange
  pause+resume с немедленным loader.

Регресс:
- Lint+type-check+format passed.
- Vitest 319/319 за 18.67 сек (+6 от 313).
- Vite build 899 ms.
- Pint + PHPStan passed.
- Pest 266/266 за 28.62 сек (backend не тронут).

Реестр v1.71→v1.72 / CLAUDE.md v1.62→v1.63.
ВСЕ unblocked production-TODO закрыты.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:17:51 +03:00

369 lines
13 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.
*
* Источник дизайна: 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,
type TenantTariff,
} from '../../composables/mockTenants';
import { mapApiAdminTenant } from '../../composables/adminTenantsMapper';
import { usePolling } from '../../composables/usePolling';
import * as adminApi from '../../api/admin';
import ImpersonationDialog from '../../components/admin/ImpersonationDialog.vue';
const router = useRouter();
// Reactive-копия — initial state = MOCK_TENANTS (UI работает без fetch'а),
// заменяется на API-данные при наличии backend.
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;
// monthlyRevenueRub — backend не считает; оставляем mock-значение.
} 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<TenantTariff[]>([]);
const impersonationOpen = ref(false);
const impersonationTenant = ref<AdminTenant | null>(null);
const STATUS_OPTIONS: Array<{ value: TenantStatus; title: string }> = [
{ value: 'active', title: 'Активен' },
{ value: 'trial', title: 'Trial' },
{ value: 'overdue', title: 'Просрочка' },
{ value: 'suspended', title: 'Приостановлен' },
];
const availableTariffs = computed(() => Array.from(new Set(tenantsState.map((t) => t.tariff))).sort());
function clearFilters() {
filterStatuses.value = [];
filterTariffs.value = [];
}
/**
* На MVP saas-admin auth не реализован — backend ожидает requested_by параметром.
* Production: backend будет брать из request()->user()->id, frontend перестанет это
* передавать. Заглушка id=1 = admin user из mock seed-данных.
*/
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;
});
});
function formatRub(v: number): string {
return new Intl.NumberFormat('ru-RU').format(v) + ' ₽';
}
function formatBalance(v: number): string {
if (v === 0) return '0';
if (v < 0) return '' + new Intl.NumberFormat('ru-RU').format(Math.abs(v));
return new Intl.NumberFormat('ru-RU').format(v);
}
function statusColor(s: TenantStatus): string {
if (s === 'active') return 'success';
if (s === 'trial') return 'info';
if (s === 'overdue') return 'warning';
return 'error';
}
</script>
<template>
<v-container fluid class="admin-tenants pa-6">
<header class="page-head">
<div>
<h1 class="text-h4 mb-2 page-title">Тенанты</h1>
<div class="page-stats text-body-2 text-medium-emphasis">
<span
><span class="num">{{ stats.total }}</span> всего</span
>
<span class="sep">·</span>
<span
><span class="num text-success">{{ stats.active }}</span> активны</span
>
<span class="sep">·</span>
<span
><span class="num">{{ stats.trial }}</span> trial</span
>
<span class="sep">·</span>
<span
><span class="num text-warning">{{ stats.overdue }}</span> просрочка</span
>
<span class="sep">·</span>
<span
>выручка месяц
<span class="num text-primary">{{ formatRub(stats.monthlyRevenueRub) }}</span></span
>
</div>
</div>
<div class="d-flex ga-2">
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="loading"
data-testid="reload-btn"
@click="loadTenants"
>
Обновить
</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-download">Экспорт</v-btn>
</div>
</header>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
closable
class="mt-3"
data-testid="fetch-error-alert"
>
Backend недоступен показаны mock-данные.
</v-alert>
<div class="filter-bar mt-4">
<v-text-field
v-model="search"
placeholder="ИНН, юр. лицо, email админа…"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
clearable
class="search-input"
/>
<v-select
v-model="filterStatuses"
:items="STATUS_OPTIONS"
multiple
chips
closable-chips
clearable
variant="outlined"
density="compact"
hide-details
label="Статус"
style="min-width: 180px; max-width: 280px"
data-testid="filter-statuses"
/>
<v-select
v-model="filterTariffs"
:items="availableTariffs"
multiple
chips
closable-chips
clearable
variant="outlined"
density="compact"
hide-details
label="Тариф"
style="min-width: 180px; max-width: 280px"
data-testid="filter-tariffs"
/>
<v-btn
v-if="filterStatuses.length > 0 || filterTariffs.length > 0"
variant="text"
size="small"
prepend-icon="mdi-filter-off"
data-testid="clear-filters-btn"
@click="clearFilters"
>
Сбросить
</v-btn>
</div>
<v-card variant="outlined" class="mt-4 panel">
<v-data-table
:items="filteredTenants"
:headers="[
{ title: 'Тенант', key: 'name', sortable: false },
{ title: 'Статус', key: 'status', sortable: false },
{ title: 'Тариф', key: 'tariff', sortable: false },
{ title: 'Баланс ₽', key: 'balanceRub', align: 'end', sortable: false },
{ title: 'Желаем×факт сегодня', key: 'today', align: 'end', sortable: false },
{ title: 'MRR', key: 'mrrRub', align: 'end', sortable: false },
{ title: 'Активность', key: 'activitySince', sortable: false },
{ title: '', key: 'actions', align: 'end', sortable: false, width: 56 },
]"
items-per-page="-1"
hide-default-footer
hover
density="comfortable"
@click:row="(_e: Event, { item }: { item: AdminTenant }) => openTenantDetail(item)"
>
<template #[`item.name`]="{ item }">
<div class="cell-tenant">
<div class="t-name">{{ item.name }}</div>
<div class="t-inn text-caption text-medium-emphasis">ИНН {{ item.inn }}</div>
</div>
</template>
<template #[`item.status`]="{ item }">
<v-chip size="small" variant="tonal" :color="statusColor(item.status)">
{{ item.statusText }}
</v-chip>
</template>
<template #[`item.balanceRub`]="{ item }">
<span
class="num"
:class="{ 'text-error': item.balanceRub < 0, 'text-medium-emphasis': item.balanceRub === 0 }"
>
{{ formatBalance(item.balanceRub) }}
</span>
</template>
<template #[`item.today`]="{ item }">
<span class="num">{{ item.todayDesired }} × {{ item.todayActual }}</span>
</template>
<template #[`item.mrrRub`]="{ item }">
<span v-if="item.mrrRub !== null" class="num">{{ formatRub(item.mrrRub) }}</span>
<span v-else class="text-medium-emphasis"></span>
</template>
<template #[`item.activitySince`]="{ item }">
<span class="num text-medium-emphasis">{{ item.activitySince }}</span>
</template>
<template #[`item.actions`]="{ item }">
<v-tooltip text="Войти как клиент (impersonation)" location="top">
<template #activator="{ props: tipProps }">
<v-btn
v-bind="tipProps"
icon="mdi-account-switch"
variant="text"
size="small"
density="comfortable"
:disabled="item.status === 'suspended'"
:data-testid="`impersonate-btn-${item.id}`"
@click.stop="openImpersonation(item)"
/>
</template>
</v-tooltip>
</template>
</v-data-table>
</v-card>
<ImpersonationDialog v-model="impersonationOpen" :tenant="impersonationTenant" :requested-by="ADMIN_USER_ID" />
</v-container>
</template>
<style scoped>
.admin-tenants {
max-width: 1440px;
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.page-title {
font-variation-settings: 'opsz' 28;
letter-spacing: -0.018em;
}
.page-stats {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.page-stats .sep {
color: #92907b;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 500;
}
.filter-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.search-input {
flex: 1 1 320px;
max-width: 360px;
}
.panel {
background: #fff;
}
.cell-tenant {
padding: 4px 0;
}
.t-name {
font-weight: 500;
color: #081319;
}
</style>