Files
portal/app/resources/js/components/billing/BalanceCard.vue
T
Дмитрий 0bcafe7ad6 fix/runway: единый прогноз дашборд↔биллинг при нет активных проектов B1-2
Раньше при 0 активных проектов дашборд показывал хватит на 0 дней при полном балансе,
а биллинг — Запас бесконечность. Унифицировано: оба показывают нет активных проектов
прочерк. Бэкенд дашборда больше не приводит null к 0; фронт рисует null как нет проектов.
Заодно поправлен пред-существующий красный тест 28 дня на верную форму 28 дней.
TDD: DashboardSummaryTest 11/11, фронт BalanceCard/Dashboard/Billing 27/27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:21:55 +03:00

204 lines
8.1 KiB
Vue

<script setup lang="ts">
/**
* BalanceCard — верхний ряд биллинга: Кошелёк ₽ (тёмный герой) → ≈ Лиды → Запас.
* Данные — из GET /api/billing/wallet (E3) + живой заказ из tenant-store.
*
* 22.06.2026: карточка «Тариф» убрана — тарифов как сущности нет (цена за лид по
* объёму, 7 ступеней). На её место — «Запас»: на сколько хватит баланса ПРИ ТЕКУЩЕМ
* заказе проектов (requiredLeadsPerDay). Считаем так же, как BalanceCapacityIndicator,
* чтобы число согласовалось с подписью «при N лидов в день»: 0/день → ∞ (не расходуется),
* N/день → affordableLeads / N. НЕ берём бэкендный runway_days (он по исторической
* скорости за 30 дней и противоречит живому заказу).
*/
import { computed } from 'vue';
import { leadsWord } from '../../utils/plural';
const props = defineProps<{
walletRub: number;
affordableLeads: number;
currentTierPriceRub: string;
requiredLeadsPerDay?: number;
}>();
defineEmits<{ topup: [] }>();
const walletText = computed(() => new Intl.NumberFormat('ru-RU').format(props.walletRub));
const affordableLeadsText = computed(() => new Intl.NumberFormat('ru-RU').format(props.affordableLeads));
// Живой заказ проектов (лидов/день). 0 → нет активных проектов → прогноз не считаем.
// B1-2 (UX-аудит 25.06): раньше показывали «∞ дн.», а дашборд «0 дней» — разнобой.
// Унифицировано: оба показывают «—» + «нет активных проектов».
const perDay = computed(() => Number(props.requiredLeadsPerDay ?? 0));
const runwayDays = computed(() => (perDay.value > 0 ? Math.floor(props.affordableLeads / perDay.value) : null));
const runwayText = computed(() => (runwayDays.value === null ? '—' : `${runwayDays.value} дн.`));
const runwaySub = computed(() =>
perDay.value > 0 ? `при ${perDay.value} ${leadsWord(perDay.value)} в день` : 'нет активных проектов',
);
</script>
<template>
<div class="chain-cards mt-4">
<!-- Кошелёк тёмный герой, главное действие «Пополнить». -->
<v-card variant="flat" color="secondary" class="wallet-card primary pa-4">
<div class="wallet-h">
<span class="wallet-label">Кошелёк </span>
<v-chip size="x-small" color="primary" variant="elevated">LIVE</v-chip>
</div>
<div class="wallet-amount mt-2">
<span class="num">{{ walletText }}</span>
<span class="ru">&nbsp;</span>
</div>
<div class="wallet-foot mt-3">мин. пополнение <strong>100 </strong></div>
<div class="wallet-actions mt-3">
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" size="small" @click="$emit('topup')"
>Пополнить</v-btn
>
<v-tooltip text="Автопополнение будет доступно после подключения платёжного шлюза.">
<template #activator="{ props: tipProps }">
<span v-bind="tipProps" class="d-inline-flex">
<v-btn variant="outlined" prepend-icon="mdi-autorenew" size="small" disabled>
Автопополнение
</v-btn>
</span>
</template>
</v-tooltip>
</div>
</v-card>
<div class="chain-arrow" aria-hidden="true"></div>
<!-- Лиды сколько лидов покрывает баланс по текущей цене. -->
<v-card variant="flat" class="wallet-card pa-4">
<span class="wallet-label"> Лиды</span>
<div class="wallet-amount mt-2">
<span class="num"> {{ affordableLeadsText }}</span>
<span class="ru-text">&nbsp;{{ leadsWord(affordableLeads) }}</span>
</div>
<div class="wallet-foot mt-2">
сейчас по {{ currentTierPriceRub }} /лид
<v-tooltip text="Точный расчёт по текущим ценам. Меняется при переходе ступеней.">
<template #activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="14" class="ml-1">mdi-information-outline</v-icon>
</template>
</v-tooltip>
</div>
</v-card>
<div class="chain-arrow" aria-hidden="true"></div>
<!-- Запас на сколько хватит баланса при текущем заказе проектов. -->
<v-card variant="flat" class="wallet-card pa-4">
<span class="wallet-label">Запас</span>
<div class="wallet-amount mt-2">
<span class="num">{{ runwayText }}</span>
</div>
<div class="wallet-foot mt-2">
{{ runwaySub }}
<v-tooltip text="На сколько хватит баланса при текущем дневном заказе проектов. Без заказов баланс не расходуется.">
<template #activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="14" class="ml-1">mdi-information-outline</v-icon>
</template>
</v-tooltip>
</div>
</v-card>
</div>
</template>
<style scoped>
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 500;
}
/* Ряд из трёх карточек одинаковой высоты + стрелки между ними. */
.chain-cards {
display: flex;
align-items: stretch;
gap: 0;
}
.chain-cards > .wallet-card {
flex: 1 1 0;
/* height НЕ задаём — align-items: stretch уже растягивает все три до одной высоты */
}
.chain-arrow {
flex: 0 0 auto;
align-self: center;
padding: 0 12px;
color: #b6a98c;
font-size: 22px;
line-height: 1;
font-family: 'JetBrains Mono', ui-monospace, monospace;
user-select: none;
}
/* На узких экранах ряд складывается в столбец, стрелки поворачиваются вниз. */
@media (max-width: 960px) {
.chain-cards {
flex-direction: column;
}
.chain-arrow {
transform: rotate(90deg);
padding: 8px 0;
}
}
/* Единый «фрейм» всех трёх карточек: одинаковая рамка, радиус, фон. */
.wallet-card {
border: 1px solid #e4e0d7;
border-radius: 14px;
background: #fff;
}
.wallet-card.primary {
color: #fff;
background: #012019 !important;
border-color: #012019;
}
.wallet-h {
display: flex;
align-items: center;
justify-content: space-between;
}
.wallet-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #66635c;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.wallet-card.primary .wallet-label {
color: #7a8c87;
}
.wallet-amount {
font-size: 32px;
font-weight: 600;
line-height: 1.1;
}
.wallet-amount .num {
color: inherit;
letter-spacing: -0.01em;
}
.wallet-amount .ru,
.wallet-amount .ru-text {
color: #66635c;
font-weight: 500;
font-size: 18px;
}
.wallet-card.primary .wallet-amount .ru {
color: #7a8c87;
}
.wallet-foot {
color: inherit;
opacity: 0.7;
font-size: 12px;
}
.wallet-card.primary .wallet-foot {
color: #b1c2bd;
opacity: 1;
}
.wallet-actions {
display: flex;
gap: 8px;
}
</style>