Files
portal/app/resources/js/views/sales/SalesOverviewView.vue
T
Дмитрий dfb41751c8 feat(sales): показ дохода «Заработал» в списке/карточке/сводке (фронт)
Task 3.3b — три экрана Фазы 1 показывают живой earned_rub вместо
заглушки «—»:
- «Мои клиенты»: колонка «Заработал» = комиссия по клиенту (или «—»
  без привязки).
- Карточка клиента: KPI «Вы заработали (период)».
- Сводка: KPI «Я заработал» = суммарная комиссия за период.
Тип earned_rub расширен до number|null. Vitest-моки/ассерты обновлены
под реальные значения. Фронт-набор 1052 зелёных, ESLint чист.

Фаза 3 завершена: доход менеджера считается по тарифу каждого клиента
и виден во всех экранах.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:50:24 +03:00

416 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Портал продаж → Сводка менеджера (Task 1.5).
*
* Дашборд менеджера на маршруте /sales. Реализует #page-overview из v8_sales.html.
* Данные: GET /api/sales/overview?period=...&from=...&to=...
* При смене периода данные перегружаются автоматически (watch queryParams).
*/
import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { getSalesOverview, type SalesOverview } from '../../api/sales';
import { useSalesPeriodStore } from '../../stores/salesPeriod';
import HelpHint from '../../components/sales/HelpHint.vue';
const router = useRouter();
const periodStore = useSalesPeriodStore();
// ─── state ────────────────────────────────────────────────────────────────────
const overview = ref<SalesOverview | null>(null);
const loading = ref(false);
const fetchError = ref(false);
// ─── status chips ─────────────────────────────────────────────────────────────
interface StatusMeta {
label: string;
color: string;
variant: 'tonal' | 'flat';
}
const STATUS_META: Record<string, StatusMeta> = {
trial: { label: 'Триал', color: 'blue-grey', variant: 'tonal' },
active: { label: 'Активен', color: 'success', variant: 'tonal' },
overdue: { label: 'Просрочка', color: 'error', variant: 'tonal' },
suspended: { label: 'Приостановлен', color: 'grey', variant: 'tonal' },
};
function statusMeta(s: string): StatusMeta {
return STATUS_META[s] ?? { label: s, color: 'grey', variant: 'tonal' };
}
// ─── formatters ───────────────────────────────────────────────────────────────
function fmtMoney(val: string | number | null | undefined): string {
if (val === null || val === undefined) return '—';
const n = typeof val === 'string' ? parseFloat(val) : val;
if (isNaN(n)) return '—';
return n.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' ₽';
}
function fmtNumber(val: number | null | undefined): string {
if (val === null || val === undefined) return '—';
return val.toLocaleString('ru-RU');
}
function fmtRunway(days: number | null): string {
if (days === null || days === undefined) return '—';
return days + ' дн.';
}
// ─── data loading ─────────────────────────────────────────────────────────────
async function load() {
loading.value = true;
fetchError.value = false;
try {
overview.value = await getSalesOverview(periodStore.queryParams);
} catch {
fetchError.value = true;
} finally {
loading.value = false;
}
}
watch(() => periodStore.queryParams, load, { deep: true });
onMounted(load);
// ─── navigation ───────────────────────────────────────────────────────────────
function openClient(tenantId: number) {
router.push('/sales/clients/' + tenantId);
}
defineExpose({ overview, loading, fetchError, load });
</script>
<template>
<v-container fluid class="so-page pa-6">
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-5 flex-wrap ga-3">
<div>
<h1 class="text-h5 font-weight-bold so-page-title">Сводка</h1>
<div class="so-page-meta mt-1">итоги по всем вашим клиентам</div>
</div>
<v-btn color="primary" class="text-none" prepend-icon="mdi-plus" @click="router.push('/sales/attach')">
Привязать клиента
</v-btn>
</div>
<!-- Error alert -->
<v-alert v-if="fetchError" type="warning" variant="tonal" density="compact" closable class="mb-4">
Не удалось загрузить данные. Попробуйте обновить страницу.
</v-alert>
<!-- Loading bar -->
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
<!-- KPI tiles -->
<div class="so-kpi-row mb-5" data-testid="kpi-row">
<!-- Клиентов закреплено -->
<div class="so-kpi" data-testid="kpi-clients">
<div class="so-k-label">Клиентов закреплено</div>
<div class="so-k-val">{{ overview?.totals.clients_count ?? '—' }}</div>
<div v-if="overview" class="so-k-sub">
{{ overview.totals.active }} активных · {{ overview.totals.trial }} триал ·
{{ overview.totals.overdue }} просрочка
</div>
</div>
<!-- Σ Баланс -->
<div class="so-kpi" data-testid="kpi-balance">
<div class="so-k-label">Σ баланс клиентов</div>
<div class="so-k-val">{{ overview ? fmtMoney(overview.totals.balance_sum_rub) : '—' }}</div>
<div class="so-k-sub">суммарно на счетах</div>
</div>
<!-- Лидов пришло -->
<div class="so-kpi" data-testid="kpi-leads">
<div class="so-k-label">Лидов пришло</div>
<div class="so-k-val so-k-val--accent">{{ fmtNumber(overview?.totals.leads_delivered) }}</div>
<div class="so-k-sub">доставлено по всем проектам</div>
</div>
<!-- Оборот -->
<div class="so-kpi" data-testid="kpi-oborot">
<div class="so-k-label">
Оборот клиентов
<HelpHint text="Сколько все ваши клиенты потратили на лиды за период" />
</div>
<div class="so-k-val">{{ overview ? fmtMoney(overview.totals.oborot_rub) : '—' }}</div>
<div class="so-k-sub">потрачено клиентами на лиды</div>
</div>
<!-- Я заработал суммарная комиссия по тарифам клиентов за период -->
<div class="so-kpi" data-testid="kpi-earned">
<div class="so-k-label">
Я заработал
<HelpHint text="Ваша комиссия за период — считается по тарифу каждого клиента" />
</div>
<div class="so-k-val so-k-val--accent" data-testid="earned-value">
{{ overview ? fmtMoney(overview.totals.earned_rub) : '—' }}
</div>
<div class="so-k-sub">по тарифам клиентов</div>
</div>
</div>
<!-- Explanatory green note -->
<div class="so-note so-note--ok mb-5" data-testid="green-note">
<v-icon size="16" style="flex-shrink: 0; margin-top: 1px">mdi-check</v-icon>
<div>
<strong>«Оборот клиентов»</strong> сколько ваши клиенты потратили на лиды.
<strong>«Я заработал»</strong> ваша комиссия по тарифу <strong>каждого</strong> клиента (тариф задаёт
начальник; у разных клиентов может быть разный). Комиссии появятся когда тарифы будут настроены (фаза
3). Подробности в карточке клиента и в «Мой доход».
</div>
</div>
<!-- Two-column layout: attention + top clients -->
<div class="so-cols">
<!-- Требуют внимания -->
<v-card variant="outlined" class="so-section">
<div class="so-section-header">
<span class="so-section-title">Требуют внимания</span>
<span class="so-section-extra">клиенты с проблемой баланса</span>
</div>
<v-table density="compact" data-testid="attention-table">
<thead>
<tr>
<th>Клиент</th>
<th class="text-right so-num">Баланс</th>
<th>Статус</th>
<th class="text-right so-num">Запас</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in overview?.attention"
:key="row.tenant_id"
class="so-row"
data-testid="attention-row"
@click="openClient(row.tenant_id)"
>
<td class="so-client-name">{{ row.organization_name }}</td>
<td class="text-right so-num so-mono">{{ fmtMoney(row.balance_rub) }}</td>
<td>
<v-chip
:color="statusMeta(row.status).color"
:variant="statusMeta(row.status).variant"
size="x-small"
data-testid="status-chip"
>
{{ statusMeta(row.status).label }}
</v-chip>
</td>
<td class="text-right so-num so-mono">{{ fmtRunway(row.runway_days) }}</td>
</tr>
<!-- Empty state -->
<tr v-if="!loading && (!overview?.attention || overview.attention.length === 0)">
<td colspan="4" class="text-center text-medium-emphasis pa-5" data-testid="attention-empty">
Все клиенты в порядке
</td>
</tr>
</tbody>
</v-table>
</v-card>
<!-- Топ клиентов по лидам -->
<v-card variant="outlined" class="so-section">
<div class="so-section-header">
<span class="so-section-title">Топ клиентов по лидам</span>
<span class="so-section-extra">за период</span>
</div>
<v-table density="compact" data-testid="top-clients-table">
<thead>
<tr>
<th>Клиент</th>
<th class="text-right so-num">Пришло</th>
<th class="text-right so-num">Оборот</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in overview?.top_clients"
:key="row.tenant_id"
class="so-row"
data-testid="top-client-row"
@click="openClient(row.tenant_id)"
>
<td class="so-client-name">{{ row.organization_name }}</td>
<td class="text-right so-num so-mono">{{ fmtNumber(row.leads_delivered) }}</td>
<td class="text-right so-num so-mono">{{ fmtMoney(row.oborot_rub) }}</td>
</tr>
<!-- Empty state -->
<tr v-if="!loading && (!overview?.top_clients || overview.top_clients.length === 0)">
<td colspan="3" class="text-center text-medium-emphasis pa-5" data-testid="top-empty">
Нет данных за выбранный период
</td>
</tr>
</tbody>
</v-table>
</v-card>
</div>
</v-container>
</template>
<style scoped>
.so-page {
max-width: 1500px;
}
.so-page-title {
color: #081319;
letter-spacing: -0.02em;
}
.so-page-meta {
font-size: 12.5px;
color: #66635c;
}
/* KPI row */
.so-kpi-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
@media (max-width: 1100px) {
.so-kpi-row {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 700px) {
.so-kpi-row {
grid-template-columns: 1fr 1fr;
}
}
.so-kpi {
background: #fffdfa;
border: 1px solid #d9d5cd;
border-radius: 10px;
padding: 14px 16px;
}
.so-k-label {
font-size: 11.5px;
color: #66635c;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 2px;
}
.so-k-val {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-feature-settings: 'tnum';
font-size: 23px;
font-weight: 600;
letter-spacing: -0.02em;
color: #081319;
line-height: 1;
}
.so-k-val--accent {
color: #0f6e56;
}
.so-k-sub {
font-size: 11px;
color: #66635c;
margin-top: 7px;
}
/* Explanatory note */
.so-note {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: 10px;
font-size: 12.5px;
line-height: 1.45;
border: 1px solid;
max-width: 980px;
}
.so-note--ok {
background: #e1eeea;
border-color: #b6d9cf;
color: #084635;
}
/* Two-column layout */
.so-cols {
display: grid;
grid-template-columns: 1fr 360px;
gap: 16px;
align-items: start;
}
@media (max-width: 900px) {
.so-cols {
grid-template-columns: 1fr;
}
}
.so-section {
overflow: hidden;
}
.so-section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px 10px;
border-bottom: 1px solid #e8e3d6;
}
.so-section-title {
font-size: 13px;
font-weight: 600;
color: #66635c;
letter-spacing: 0.005em;
text-transform: uppercase;
}
.so-section-extra {
font-size: 11px;
font-weight: 500;
color: #66635c;
text-transform: none;
}
/* Table rows */
.so-row {
cursor: pointer;
}
.so-row:hover td {
background: rgba(15, 110, 86, 0.05);
}
.so-client-name {
font-weight: 500;
color: #081319;
font-size: 13px;
}
.so-num {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.so-mono {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
}
</style>