530 lines
20 KiB
Vue
530 lines
20 KiB
Vue
<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>
|