4387333118
Фича «Конкурентное поле» на dev до уровня прототипа 2026-06-29-konkurentnoe-pole-proto.html.
Данные: box (proposal|field) на competitors+sources; phone_type city/mobile/tollfree рядом
с phone_kind (вариант C). 3 миграции, дефолты тарифов 300/50.
API (AutopodborController): GET /field (+счётчики), GET /proposals, PATCH/DELETE competitors
и sources с гвардами активного проекта, переключение box, POST /competitors/manual (+directory_urls),
competitor(id) обогащён box+project-статусом; projectStatus отдаёт limit/delivered/days/regions.
Смена источника проекта = PATCH /api/projects/{id} (реальный гвард слепка §14.10).
Фронт: FieldWorkspaceScreen/FieldCompetitorScreen/FieldProposalsScreen/FieldManualCompetitorScreen
+ field-shared.css (Forest) + AutopodborServicesPanel в Биллинге. Дословно по прототипу: подзаголовки,
баннер предложений, баннер правил времени 18:00 МСК, Справочник 2ГИС·Яндекс, статус проекта
5/день·заявки, окна сбора с ценами 300/50 + «что известно», полные формы. Пункт меню «Конкурентное поле».
Тесты: backend автоподбор 80/80, фронт автоподбор 49/49. Движок шага 2 = заглушка FakeCompetitorAgent.
OmegaDemoFieldSeeder — только для визуальной проверки (НЕ на прод).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
187 lines
7.3 KiB
Vue
187 lines
7.3 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 TierPricesPanel from '../components/billing/TierPricesPanel.vue';
|
|
import AutopodborServicesPanel from '../components/billing/AutopodborServicesPanel.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 } 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);
|
|
// Возврат с платёжной страницы шлюза (?topup=return): баланс зачислится по webhook.
|
|
const paymentReturn = 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);
|
|
|
|
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(() => {
|
|
paymentReturn.value = new URLSearchParams(window.location.search).get('topup') === 'return';
|
|
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
|
|
>
|
|
</div>
|
|
</div>
|
|
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" @click="topupOpen = true"
|
|
>Пополнить баланс</v-btn
|
|
>
|
|
</header>
|
|
|
|
<v-alert
|
|
v-if="paymentReturn"
|
|
type="info"
|
|
variant="tonal"
|
|
density="comfortable"
|
|
class="mt-4"
|
|
closable
|
|
@click:close="paymentReturn = false"
|
|
>
|
|
Платёж обрабатывается. Баланс обновится автоматически в течение минуты после подтверждения оплаты.
|
|
</v-alert>
|
|
|
|
<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"
|
|
:required-leads-per-day="tenant.requiredLeadsPerDay"
|
|
@topup="topupOpen = true"
|
|
/>
|
|
|
|
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
|
|
|
|
<AutopodborServicesPanel />
|
|
|
|
<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>
|