Files
portal/app/resources/js/views/admin/AdminBillingView.vue
T

280 lines
10 KiB
Vue
Raw Normal View History

<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"
label="Поиск"
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>