Files
portal/app/resources/js/views/admin/AdminBillingView.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

279 lines
10 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, retention, refunds.
* Источник данных: aggregate balance_transactions / invoices / tariff_subscriptions.
*
* MVP — только display-вьюха с mock-данными. Backend `/api/admin/billing/*`
* подключается отдельным коммитом.
*/
import { ADMIN_BILLING_SUMMARY as MOCK_SUMMARY, ADMIN_BILLING_TENANTS } from '../../composables/mockAdmin';
import { computed, onMounted, reactive, ref } from 'vue';
import { usePolling } from '../../composables/usePolling';
import * as adminApi from '../../api/admin';
const search = ref('');
/**
* Reactive-копия — initial = MOCK для UI без backend'а; replace на API на mount.
* View работает в обоих режимах: row может быть из mock (узкие enum-types)
* или из API (открытые string-типы).
*/
type BillingRow = {
id: number;
name: string;
inn: string; // в API нет — пустая строка
tariff: string;
balance_rub: number;
monthly_topups_rub: number;
monthly_charges_rub: number;
mrr_rub: number;
status: string;
};
const rowsState = reactive<BillingRow[]>(
ADMIN_BILLING_TENANTS.map((r) => ({
id: r.id,
name: r.name,
inn: r.inn,
tariff: r.tariff,
balance_rub: r.balance_rub,
monthly_topups_rub: r.monthly_topups_rub,
monthly_charges_rub: r.monthly_charges_rub,
mrr_rub: r.mrr_rub,
status: r.status,
})),
);
const summary = reactive({
total_mrr_rub: MOCK_SUMMARY.total_mrr_rub,
monthly_revenue_rub: MOCK_SUMMARY.monthly_revenue_rub,
overdue_count: MOCK_SUMMARY.overdue_count,
refunds_count_30d: MOCK_SUMMARY.refunds_count_30d,
});
const loading = ref(false);
const fetchError = ref(false);
function deriveStatus(api: { status: string; balance_rub: string; chargeback_unrecovered_rub: string }): string {
const balance = parseFloat(api.balance_rub);
const chargeback = parseFloat(api.chargeback_unrecovered_rub);
if (api.status === 'suspended') return 'suspended';
if (chargeback > 0 || balance < 0) return 'overdue';
return 'active';
}
async function loadBilling() {
loading.value = true;
fetchError.value = false;
try {
const res = await adminApi.listAdminBilling();
const mapped: BillingRow[] = res.tenants.map((t) => ({
id: t.id,
name: t.organization_name,
inn: '', // нет в API
tariff: t.tariff_name ?? '—',
balance_rub: parseFloat(t.balance_rub),
monthly_topups_rub: parseFloat(t.monthly_topups_rub),
monthly_charges_rub: parseFloat(t.monthly_charges_rub),
mrr_rub: parseFloat(t.mrr_rub),
status: deriveStatus(t),
}));
rowsState.splice(0, rowsState.length, ...mapped);
summary.total_mrr_rub = parseFloat(res.summary.total_mrr_rub);
summary.monthly_revenue_rub = parseFloat(res.summary.monthly_revenue_rub);
summary.overdue_count = res.summary.overdue_count;
summary.refunds_count_30d = res.summary.refunds_count_30d;
} catch {
fetchError.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadBilling);
usePolling(loadBilling);
defineExpose({ rowsState, summary, loading, fetchError, loadBilling });
const headers = [
{ title: 'Тенант', key: 'name', sortable: true },
{ title: 'Тариф', key: 'tariff', sortable: true },
{ title: 'Баланс', key: 'balance_rub', sortable: true, align: 'end' as const },
{ title: 'Пополнения за мес', key: 'monthly_topups_rub', sortable: true, align: 'end' as const },
{ title: 'Списания за мес', key: 'monthly_charges_rub', sortable: true, align: 'end' as const },
{ title: 'MRR', key: 'mrr_rub', sortable: true, align: 'end' as const },
{ title: 'Статус', key: 'status', sortable: true },
];
const filteredRows = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return rowsState;
return rowsState.filter((r) => r.name.toLowerCase().includes(q) || r.inn.includes(q));
});
const fmt = new Intl.NumberFormat('ru-RU');
const formatRub = (v: number) => `${fmt.format(v)}`;
const statusMeta: Record<string, { label: string; color: string }> = {
active: { label: 'Активен', color: 'success' },
overdue: { label: 'Просрочка', color: 'warning' },
suspended: { label: 'Заблокирован', color: 'error' },
};
function statusInfo(s: string) {
return statusMeta[s] ?? { label: s, color: 'default' };
}
// Mock-tariff slugs → русский title; backend возвращает уже переведённое имя
// (например «Команда»), которое отдаём как есть.
const tariffMap: Record<string, string> = {
start: 'Старт',
basic: 'Базовый',
pro: 'Команда',
enterprise: 'Enterprise',
};
function tariffLabel(t: string): string {
return tariffMap[t] ?? t;
}
</script>
<template>
<v-container fluid class="admin-billing pa-6">
<header class="page-head mb-4 d-flex justify-space-between align-start">
<div>
<h1 class="text-h4 page-title">Биллинг</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Сводка по всем тенантам: выручка, MRR, просрочки, возвраты.
</p>
</div>
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="loading"
data-testid="reload-btn"
@click="loadBilling"
>
Обновить
</v-btn>
</header>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
closable
class="mb-4"
data-testid="fetch-error-alert"
>
Backend недоступен показаны mock-данные.
</v-alert>
<!-- Stats row -->
<v-row class="mb-4" data-testid="billing-stats">
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">MRR</div>
<div class="text-h6 font-mono tabular">{{ formatRub(summary.total_mrr_rub) }}</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">Выручка за месяц</div>
<div class="text-h6 font-mono tabular">
{{ formatRub(summary.monthly_revenue_rub) }}
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">Просрочка</div>
<div class="text-h6 font-mono tabular text-warning">
{{ summary.overdue_count }}
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">Возвраты за 30 дн</div>
<div class="text-h6 font-mono tabular">{{ summary.refunds_count_30d }}</div>
</v-card>
</v-col>
</v-row>
<v-card variant="outlined" class="pa-4">
<div class="d-flex justify-space-between align-center mb-3">
<h2 class="text-h6 ma-0">Тенанты</h2>
<v-text-field
v-model="search"
placeholder="Поиск по названию или ИНН"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
hide-details
clearable
style="max-width: 320px"
/>
</div>
<v-data-table
:headers="headers"
:items="filteredRows"
:items-per-page="20"
density="comfortable"
class="font-mono-nums"
>
<template #[`item.name`]="{ item }">
<div>
<div class="font-weight-medium">{{ item.name }}</div>
<div class="text-caption text-medium-emphasis">ИНН {{ item.inn }}</div>
</div>
</template>
<template #[`item.tariff`]="{ item }">
{{ tariffLabel(item.tariff) }}
</template>
<template #[`item.balance_rub`]="{ item }">
<span
:class="
item.balance_rub < 0 ? 'text-error' : item.balance_rub === 0 ? 'text-medium-emphasis' : ''
"
>
{{ formatRub(item.balance_rub) }}
</span>
</template>
<template #[`item.monthly_topups_rub`]="{ item }">
{{ formatRub(item.monthly_topups_rub) }}
</template>
<template #[`item.monthly_charges_rub`]="{ item }">
{{ formatRub(item.monthly_charges_rub) }}
</template>
<template #[`item.mrr_rub`]="{ item }">
{{ formatRub(item.mrr_rub) }}
</template>
<template #[`item.status`]="{ item }">
<v-chip :color="statusInfo(item.status).color" size="small" variant="tonal">
{{ statusInfo(item.status).label }}
</v-chip>
</template>
</v-data-table>
</v-card>
</v-container>
</template>
<style scoped>
.admin-billing {
max-width: 1400px;
}
.page-title {
font-variation-settings: 'opsz' 28;
letter-spacing: -0.018em;
}
.font-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.tabular {
font-feature-settings: 'tnum';
}
</style>