0bcafe7ad6
Раньше при 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>
204 lines
8.1 KiB
Vue
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"> ₽</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"> {{ 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>
|