01c20e7b6c
Закрывает последний 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>
369 lines
13 KiB
Vue
369 lines
13 KiB
Vue
<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>
|