e1601e7862
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>
190 lines
7.5 KiB
Vue
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>
|