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

530 lines
20 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.
*
* Данные грузятся с backend GET /api/admin/billing.
*/
import { computed, onMounted, reactive, ref } from 'vue';
import { usePolling } from '../../composables/usePolling';
import * as adminApi from '../../api/admin';
import type { AdminTariffPlan } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
const search = ref('');
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[]>([]);
const summary = reactive({
total_mrr_rub: 0,
monthly_revenue_rub: 0,
overdue_count: 0,
refunds_count_30d: 0,
});
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);
// === Row-actions state (Sprint 3D G4) ===
const actionDialog = ref<null | 'status' | 'refund' | 'tariff'>(null);
const actionRow = ref<BillingRow | null>(null);
const actionReason = ref('');
const actionAmount = ref<number | null>(null);
const actionTariffId = ref<number | null>(null);
const actionLoading = ref(false);
const actionError = ref('');
const tariffPlans = ref<AdminTariffPlan[]>([]);
async function openAction(type: 'status' | 'refund' | 'tariff', row: BillingRow) {
actionDialog.value = type;
actionRow.value = row;
actionReason.value = '';
actionAmount.value = null;
actionTariffId.value = null;
actionError.value = '';
if (type === 'tariff') {
try {
tariffPlans.value = await adminApi.listAdminTariffPlans();
} catch (e) {
actionError.value = extractErrorMessage(e);
}
}
}
async function confirmAction() {
// Validate
if (actionReason.value.trim().length < 10) {
actionError.value = 'Укажите основание (минимум 10 символов).';
return;
}
if (actionDialog.value === 'refund' && (actionAmount.value === null || !Number.isFinite(actionAmount.value) || actionAmount.value <= 0)) {
actionError.value = 'Укажите сумму возврата больше нуля.';
return;
}
if (actionDialog.value === 'refund' && actionAmount.value! > actionRow.value!.balance_rub) {
actionError.value = 'Сумма возврата превышает баланс тенанта.';
return;
}
if (actionDialog.value === 'tariff' && actionTariffId.value === null) {
actionError.value = 'Выберите тарифный план.';
return;
}
const row = actionRow.value!;
actionLoading.value = true;
actionError.value = '';
try {
if (actionDialog.value === 'status') {
const newStatus = row.status === 'suspended' ? 'active' : 'suspended';
await adminApi.updateTenantStatus(row.id, newStatus, actionReason.value.trim());
} else if (actionDialog.value === 'refund') {
await adminApi.refundTenant(row.id, actionAmount.value!, actionReason.value.trim());
} else if (actionDialog.value === 'tariff') {
await adminApi.changeTenantTariff(row.id, actionTariffId.value!, actionReason.value.trim());
}
await loadBilling();
actionDialog.value = null;
} catch (e) {
actionError.value = extractErrorMessage(e);
} finally {
actionLoading.value = false;
}
}
defineExpose({
rowsState,
summary,
loading,
fetchError,
loadBilling,
actionDialog,
actionRow,
actionReason,
actionAmount,
actionTariffId,
actionError,
actionLoading,
tariffPlans,
openAction,
confirmAction,
});
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 },
{ title: '', key: 'actions', sortable: false, align: 'end' as const },
];
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"
>
Не удалось загрузить биллинг. Попробуйте обновить.
</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>
<template #[`item.actions`]="{ item }">
<v-menu>
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-dots-vertical"
:data-testid="`row-actions-${item.id}`"
variant="text"
size="small"
aria-label="Действия с тенантом"
/>
</template>
<v-list density="compact">
<v-list-item
:title="item.status === 'suspended' ? 'Разблокировать' : 'Приостановить'"
prepend-icon="mdi-account-cancel"
@click="openAction('status', item)"
/>
<v-list-item
title="Возврат средств"
prepend-icon="mdi-cash-refund"
@click="openAction('refund', item)"
/>
<v-list-item
title="Сменить тариф"
prepend-icon="mdi-swap-horizontal"
@click="openAction('tariff', item)"
/>
</v-list>
</v-menu>
</template>
</v-data-table>
</v-card>
<!-- Dialog: suspend / activate -->
<v-dialog :model-value="actionDialog === 'status'" max-width="480" @update:model-value="actionDialog = null">
<v-card>
<v-card-title>
{{ actionRow?.status === 'suspended' ? 'Разблокировать тенанта' : 'Приостановить тенанта' }}
</v-card-title>
<v-card-text>
<p class="mb-3 text-body-2">
Тенант: <strong>{{ actionRow?.name }}</strong>
</p>
<v-alert
v-if="actionError"
type="error"
variant="tonal"
density="compact"
class="mb-3"
data-testid="action-error"
>
{{ actionError }}
</v-alert>
<v-textarea
v-model="actionReason"
label="Основание"
placeholder="Минимум 10 символов"
rows="3"
variant="outlined"
density="compact"
data-testid="action-reason"
/>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
<v-btn
:loading="actionLoading"
color="primary"
variant="flat"
@click="confirmAction"
>
Подтвердить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog: refund -->
<v-dialog :model-value="actionDialog === 'refund'" max-width="480" @update:model-value="actionDialog = null">
<v-card>
<v-card-title>Возврат средств</v-card-title>
<v-card-text>
<p class="mb-3 text-body-2">
Тенант: <strong>{{ actionRow?.name }}</strong>
</p>
<v-alert
v-if="actionError"
type="error"
variant="tonal"
density="compact"
class="mb-3"
data-testid="action-error"
>
{{ actionError }}
</v-alert>
<v-text-field
v-model.number="actionAmount"
type="number"
label="Сумма возврата, ₽"
:hint="actionRow ? `доступно к возврату: ${formatRub(actionRow.balance_rub)}` : ''"
persistent-hint
variant="outlined"
density="compact"
class="mb-3"
data-testid="refund-amount"
/>
<v-textarea
v-model="actionReason"
label="Основание"
placeholder="Минимум 10 символов"
rows="3"
variant="outlined"
density="compact"
data-testid="action-reason"
/>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
<v-btn
:loading="actionLoading"
color="primary"
variant="flat"
@click="confirmAction"
>
Выполнить возврат
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog: change tariff -->
<v-dialog :model-value="actionDialog === 'tariff'" max-width="480" @update:model-value="actionDialog = null">
<v-card>
<v-card-title>Сменить тариф</v-card-title>
<v-card-text>
<p class="mb-3 text-body-2">
Тенант: <strong>{{ actionRow?.name }}</strong>
</p>
<v-alert
v-if="actionError"
type="error"
variant="tonal"
density="compact"
class="mb-3"
data-testid="action-error"
>
{{ actionError }}
</v-alert>
<v-select
v-model="actionTariffId"
:items="tariffPlans"
item-title="name"
item-value="id"
label="Тарифный план"
variant="outlined"
density="compact"
class="mb-3"
data-testid="tariff-select"
/>
<v-textarea
v-model="actionReason"
label="Основание"
placeholder="Минимум 10 символов"
rows="3"
variant="outlined"
density="compact"
data-testid="action-reason"
/>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
<v-btn
:loading="actionLoading"
color="primary"
variant="flat"
@click="confirmAction"
>
Сменить тариф
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</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>