Files
portal/app/resources/js/views/BillingView.vue
T
Дмитрий e1601e7862 feat(billing-v2-c): UI префлайт Task 1.10 — баннер заморозки, индикатор ёмкости, диалог перегрузки
Spec C §3.6/§6.2. Бэкенд: GET /api/billing/balance-status (frozen + capacity + required + дефицит ₽/leads), Pest 6. Фронт: BalanceFrozenBanner (в AppLayout, глобально), BalanceCapacityIndicator (в BillingView под балансом), ProjectLimitOverloadDialog (409-перехват в NewProjectDialog: save-blocked/set-zero), tenantStore + api getBalanceStatus. Vitest +18.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:21 +03:00

190 lines
7.5 KiB
Vue

<script setup lang="ts">
/**
* Биллинг и тарифы — финансовый экран. Кошелёк ₽, баланс лидов,
* текущий тариф, история транзакций и счета.
*
* Sprint 2 Plan C (E3): Overview-таб подвязан на real API
* (GET /api/billing/wallet → BalanceCard + шапка; TransactionsTable и
* InvoicesTable тянут данные сами). Списания — ChargesTab (Plan 4).
* Sprint 5C (E4): pending-баннер убран — платёжного шлюза нет (Б-1), реального состояния «платёж в обработке» в БД не существует.
* TopupDialog «Пополнить баланс» — Task 5 (E1).
*/
import { ref, computed, onMounted } from 'vue';
import BalanceCard from '../components/billing/BalanceCard.vue';
import BalanceCapacityIndicator from '../components/billing/BalanceCapacityIndicator.vue';
import TierPricesPanel from '../components/billing/TierPricesPanel.vue';
import TransactionsTable from '../components/billing/TransactionsTable.vue';
import InvoicesTable from '../components/billing/InvoicesTable.vue';
import TopupDialog from '../components/billing/TopupDialog.vue';
import ChargesTab from './billing/ChargesTab.vue';
import { formatPlain, featureLabel } from '../composables/billingFormatters';
import { getWallet, type Wallet } from '../api/billing';
import { extractErrorMessage } from '../api/client';
import { useTenantStore } from '../stores/tenantStore';
const activeView = ref<'overview' | 'charges'>('overview');
const tenant = useTenantStore();
const wallet = ref<Wallet | null>(null);
const loading = ref(true);
const loadError = ref<string | null>(null);
const topupOpen = ref(false);
const topupSnackbar = ref(false);
const txTableRef = ref<InstanceType<typeof TransactionsTable> | null>(null);
const walletRub = computed(() => Number(wallet.value?.balance_rub ?? 0));
const affordableLeads = computed(() => wallet.value?.affordable_leads ?? 0);
const currentTierPriceRub = computed(() => wallet.value?.current_tier?.price_rub ?? '0.00');
const tiersPreview = computed(() => wallet.value?.tiers_preview ?? []);
const currentTierNo = computed(() => wallet.value?.current_tier?.no ?? null);
const runwayDays = computed(() => wallet.value?.runway_days ?? null);
const tariffName = computed(() => wallet.value?.tariff?.name ?? null);
const tariffFeatures = computed<string[]>(() => (wallet.value?.tariff?.features ?? []).map(featureLabel));
async function loadWallet(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
wallet.value = await getWallet();
} catch (e) {
// Сброс устаревших данных: при неудачном повторе не оставляем
// прошлый успешный wallet в памяти (защита от ложного рендера).
wallet.value = null;
loadError.value = extractErrorMessage(e, 'Не удалось загрузить данные биллинга.');
} finally {
loading.value = false;
}
}
async function onTopupSuccess(): Promise<void> {
// success-событие несёт новый баланс, но мы намеренно перезапрашиваем
// кошелёк (loadWallet) — единый источник истины надёжнее точечного патча.
topupOpen.value = false;
topupSnackbar.value = true;
await loadWallet();
// Пополнение могло снять заморозку → обновляем статус баланса (баннер/индикатор).
await tenant.load();
txTableRef.value?.refresh();
}
onMounted(() => {
void loadWallet();
void tenant.load();
});
defineExpose({ loadWallet, wallet, topupOpen });
</script>
<template>
<v-container fluid class="billing pa-6">
<header class="page-head">
<div>
<h1 class="text-h4 mb-2 page-title">Биллинг и тарифы</h1>
<div v-if="wallet" class="page-stats text-body-2 text-medium-emphasis">
<span
><span class="num text-primary">{{ formatPlain(walletRub) }}</span> кошелёк</span
>
<template v-if="runwayDays !== null">
<span class="sep">·</span>
<span
>хватит на <span class="num">{{ runwayDays }}</span> дн.</span
>
</template>
</div>
</div>
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" @click="topupOpen = true"
>Пополнить баланс</v-btn
>
</header>
<v-tabs v-model="activeView" color="primary" class="mt-4">
<v-tab value="overview">Обзор</v-tab>
<v-tab value="charges">Списания</v-tab>
</v-tabs>
<v-tabs-window v-model="activeView">
<v-tabs-window-item value="overview">
<div v-if="loading" class="py-12 d-flex justify-center">
<v-progress-circular indeterminate color="primary" />
</div>
<v-alert v-else-if="loadError" type="error" variant="tonal" class="mt-4" role="alert">
{{ loadError }}
<template #append>
<v-btn size="small" variant="text" @click="loadWallet">Повторить</v-btn>
</template>
</v-alert>
<template v-else-if="wallet">
<BalanceCard
:wallet-rub="walletRub"
:affordable-leads="affordableLeads"
:current-tier-price-rub="currentTierPriceRub"
:tariff-name="tariffName"
:tariff-features="tariffFeatures"
@topup="topupOpen = true"
/>
<BalanceCapacityIndicator
v-if="tenant.status"
class="mt-3"
:balance-rub="tenant.balanceRub"
:capacity-leads="tenant.capacityLeads"
:required-leads-per-day="tenant.requiredLeadsPerDay"
/>
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
<TransactionsTable ref="txTableRef" />
<InvoicesTable />
</template>
</v-tabs-window-item>
<v-tabs-window-item value="charges">
<ChargesTab />
</v-tabs-window-item>
</v-tabs-window>
<TopupDialog v-model="topupOpen" @success="onTopupSuccess" />
<v-snackbar v-model="topupSnackbar" color="success" :timeout="4000">
Баланс пополнен.
</v-snackbar>
</v-container>
</template>
<style scoped>
.billing {
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 {
/* WCAG2AA 4.5:1: #6b6356 → 5.33:1 on ivory. */
color: #6b6356;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 500;
}
</style>