dfb41751c8
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>
416 lines
15 KiB
Vue
416 lines
15 KiB
Vue
<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>
|