7cdbbdae46
Task 6.1b — SalesBossOverviewView (начальник, #page-boss-overview) поверх GET /api/sales/dashboard/overview. - 5 KPI (менеджеров с active/vacation, клиентов, Σ баланс, оборот отдела, выплачено за период). - 4 кликабельные плитки-алерта → переходы: счета→/sales/invoices, заявки→/sales/requests, проблемы баланса→/sales/performance, провести выплату→/sales/payouts. - Мини-таблица результативности менеджеров (клиенты/лиды/оборот/выплачено/ заработал) с HelpHint. Период из salesPeriod store. - Роут /sales/boss со заглушки на экран. Vitest 12/12, фронт-набор 1078 без регрессий, ESLint чист. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
451 lines
15 KiB
Vue
451 lines
15 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Портал продаж → Сводка отдела (Task 6.1b, начальник).
|
||
*
|
||
* Дашборд начальника на маршруте /sales/boss. Реализует #page-boss-overview из v8_sales.html.
|
||
* Данные: GET /api/sales/dashboard/overview?period=...&from=...&to=...
|
||
* При смене периода данные перегружаются автоматически (watch queryParams).
|
||
*/
|
||
import { onMounted, ref, watch } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
import { getSalesDashboardOverview, type SalesDashboardOverview } 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<SalesDashboardOverview | null>(null);
|
||
const loading = ref(false);
|
||
const fetchError = ref(false);
|
||
|
||
// ─── 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');
|
||
}
|
||
|
||
// ─── data loading ─────────────────────────────────────────────────────────────
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
fetchError.value = false;
|
||
try {
|
||
overview.value = await getSalesDashboardOverview(periodStore.queryParams);
|
||
} catch {
|
||
fetchError.value = true;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
watch(() => periodStore.queryParams, load, { deep: true });
|
||
|
||
onMounted(load);
|
||
|
||
// ─── navigation ───────────────────────────────────────────────────────────────
|
||
|
||
function go(path: string) {
|
||
router.push(path);
|
||
}
|
||
|
||
defineExpose({ overview, loading, fetchError, load });
|
||
</script>
|
||
|
||
<template>
|
||
<v-container fluid class="bo-page pa-6">
|
||
<!-- Header -->
|
||
<div class="mb-5">
|
||
<h1 class="text-h5 font-weight-bold bo-page-title">Сводка отдела</h1>
|
||
<div class="bo-page-meta mt-1">весь отдел продаж одним взглядом</div>
|
||
</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="bo-kpi-row mb-5" data-testid="kpi-row">
|
||
<!-- Менеджеров -->
|
||
<div class="bo-kpi" data-testid="kpi-managers">
|
||
<div class="bo-k-label">Менеджеров</div>
|
||
<div class="bo-k-val">{{ overview?.kpi.managers_count ?? '—' }}</div>
|
||
<div v-if="overview" class="bo-k-sub">
|
||
{{ overview.kpi.managers_active }} активны · {{ overview.kpi.managers_vacation }} в отпуске
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Клиентов закреплено -->
|
||
<div class="bo-kpi" data-testid="kpi-clients">
|
||
<div class="bo-k-label">Клиентов закреплено</div>
|
||
<div class="bo-k-val">{{ overview?.kpi.clients_count ?? '—' }}</div>
|
||
<div class="bo-k-sub">по всем менеджерам</div>
|
||
</div>
|
||
|
||
<!-- Σ баланс клиентов -->
|
||
<div class="bo-kpi" data-testid="kpi-balance">
|
||
<div class="bo-k-label">Σ баланс клиентов</div>
|
||
<div class="bo-k-val">{{ overview ? fmtMoney(overview.kpi.balance_sum_rub) : '—' }}</div>
|
||
<div class="bo-k-sub">суммарно на счетах</div>
|
||
</div>
|
||
|
||
<!-- Оборот отдела (мес) -->
|
||
<div class="bo-kpi" data-testid="kpi-oborot">
|
||
<div class="bo-k-label">
|
||
Оборот отдела (мес)
|
||
<HelpHint text="Сколько все клиенты отдела потратили на лиды за период" />
|
||
</div>
|
||
<div class="bo-k-val bo-k-val--accent">{{ overview ? fmtMoney(overview.kpi.oborot_rub) : '—' }}</div>
|
||
<div class="bo-k-sub">потрачено клиентами на лиды</div>
|
||
</div>
|
||
|
||
<!-- Выплачено менеджерам (мес) -->
|
||
<div class="bo-kpi" data-testid="kpi-paid">
|
||
<div class="bo-k-label">Выплачено менеджерам (мес)</div>
|
||
<div class="bo-k-val">{{ overview ? fmtMoney(overview.kpi.paid_period_rub) : '—' }}</div>
|
||
<div class="bo-k-sub">за период</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Alert cards -->
|
||
<div class="bo-alert-row mb-5" data-testid="alert-row">
|
||
<button
|
||
type="button"
|
||
class="bo-acard"
|
||
data-alert-card
|
||
data-testid="alert-invoices"
|
||
@click="go('/sales/invoices')"
|
||
>
|
||
<span class="bo-ac-ic bo-ac-ic--amber">
|
||
<v-icon size="20">mdi-file-document-outline</v-icon>
|
||
</span>
|
||
<span class="bo-ac-body">
|
||
<span class="bo-ac-n">{{ overview?.alerts.invoices_awaiting ?? '—' }}</span>
|
||
<span class="bo-ac-l">счёта ждут оплаты</span>
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
class="bo-acard"
|
||
data-alert-card
|
||
data-testid="alert-requests"
|
||
@click="go('/sales/requests')"
|
||
>
|
||
<span class="bo-ac-ic bo-ac-ic--blue">
|
||
<v-icon size="20">mdi-account-check-outline</v-icon>
|
||
</span>
|
||
<span class="bo-ac-body">
|
||
<span class="bo-ac-n">{{ overview?.alerts.attachments_pending ?? '—' }}</span>
|
||
<span class="bo-ac-l">заявки на привязку</span>
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
class="bo-acard"
|
||
data-alert-card
|
||
data-testid="alert-balance"
|
||
@click="go('/sales/performance')"
|
||
>
|
||
<span class="bo-ac-ic bo-ac-ic--red">
|
||
<v-icon size="20">mdi-alert-outline</v-icon>
|
||
</span>
|
||
<span class="bo-ac-body">
|
||
<span class="bo-ac-n">{{ overview?.alerts.clients_balance_problem ?? '—' }}</span>
|
||
<span class="bo-ac-l">клиента с проблемой баланса</span>
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
class="bo-acard"
|
||
data-alert-card
|
||
data-testid="alert-payout"
|
||
@click="go('/sales/payouts')"
|
||
>
|
||
<span class="bo-ac-ic bo-ac-ic--green">
|
||
<v-icon size="20">mdi-plus</v-icon>
|
||
</span>
|
||
<span class="bo-ac-body">
|
||
<span class="bo-ac-n">+</span>
|
||
<span class="bo-ac-l">провести выплату</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Performance table -->
|
||
<v-card variant="outlined" class="bo-section">
|
||
<div class="bo-section-header">
|
||
<span class="bo-section-title">Результативность менеджеров</span>
|
||
<span class="bo-section-extra">за период</span>
|
||
</div>
|
||
<v-table density="compact" data-testid="performance-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Менеджер</th>
|
||
<th class="text-right bo-num">Клиентов</th>
|
||
<th class="text-right bo-num">Лидов пришло</th>
|
||
<th class="text-right bo-num">
|
||
Оборот клиентов ₽
|
||
<HelpHint text="Сколько потратили на лиды все клиенты этого менеджера за период" />
|
||
</th>
|
||
<th class="text-right bo-num">Выплачено ₽</th>
|
||
<th class="text-right bo-num">
|
||
Заработал (комиссия)
|
||
<HelpHint text="Комиссия менеджера — сумма по тарифам всех его клиентов" />
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in overview?.performance" :key="row.manager_id" data-testid="performance-row">
|
||
<td class="bo-mgr-name">{{ row.name }}</td>
|
||
<td class="text-right bo-num bo-mono">{{ fmtNumber(row.clients_count) }}</td>
|
||
<td class="text-right bo-num bo-mono">{{ fmtNumber(row.leads_delivered) }}</td>
|
||
<td class="text-right bo-num bo-mono">{{ fmtNumber(row.oborot_rub) }}</td>
|
||
<td class="text-right bo-num bo-mono">{{ fmtNumber(row.paid_all_time_rub) }}</td>
|
||
<td class="text-right bo-num bo-mono">{{ fmtNumber(row.earned_rub) }}</td>
|
||
</tr>
|
||
|
||
<!-- Empty state -->
|
||
<tr v-if="!loading && (!overview?.performance || overview.performance.length === 0)">
|
||
<td colspan="6" class="text-center text-medium-emphasis pa-5" data-testid="performance-empty">
|
||
Нет данных за выбранный период
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</v-table>
|
||
<div class="bo-hint">
|
||
«Заработал (комиссия)» — по тарифу каждого клиента менеджера (тарифы — в разделе «Тарифы менеджеров»).
|
||
</div>
|
||
</v-card>
|
||
</v-container>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.bo-page {
|
||
max-width: 1500px;
|
||
}
|
||
|
||
.bo-page-title {
|
||
color: #081319;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.bo-page-meta {
|
||
font-size: 12.5px;
|
||
color: #66635c;
|
||
}
|
||
|
||
/* KPI row */
|
||
.bo-kpi-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 12px;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.bo-kpi-row {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 700px) {
|
||
.bo-kpi-row {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
}
|
||
|
||
.bo-kpi {
|
||
background: #fffdfa;
|
||
border: 1px solid #d9d5cd;
|
||
border-radius: 10px;
|
||
padding: 14px 16px;
|
||
}
|
||
|
||
.bo-k-label {
|
||
font-size: 11.5px;
|
||
color: #66635c;
|
||
margin-bottom: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
}
|
||
|
||
.bo-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;
|
||
}
|
||
|
||
.bo-k-val--accent {
|
||
color: #0f6e56;
|
||
}
|
||
|
||
.bo-k-sub {
|
||
font-size: 11px;
|
||
color: #66635c;
|
||
margin-top: 7px;
|
||
}
|
||
|
||
/* Alert cards */
|
||
.bo-alert-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 12px;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.bo-alert-row {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
}
|
||
|
||
.bo-acard {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 14px 16px;
|
||
background: #fffdfa;
|
||
border: 1px solid #d9d5cd;
|
||
border-radius: 10px;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
transition:
|
||
border-color 0.15s,
|
||
box-shadow 0.15s;
|
||
}
|
||
|
||
.bo-acard:hover {
|
||
border-color: #0f6e56;
|
||
box-shadow: 0 1px 6px rgba(15, 110, 86, 0.12);
|
||
}
|
||
|
||
.bo-acard:focus-visible {
|
||
outline: 2px solid #0f6e56;
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.bo-ac-ic {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 9px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.bo-ac-ic--amber {
|
||
background: #fbeed3;
|
||
color: #9a6b12;
|
||
}
|
||
|
||
.bo-ac-ic--blue {
|
||
background: #dbe8fb;
|
||
color: #1a5aa8;
|
||
}
|
||
|
||
.bo-ac-ic--red {
|
||
background: #f8dcdc;
|
||
color: #a82a2a;
|
||
}
|
||
|
||
.bo-ac-ic--green {
|
||
background: #e1eeea;
|
||
color: #0f6e56;
|
||
}
|
||
|
||
.bo-ac-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.bo-ac-n {
|
||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||
font-variant-numeric: tabular-nums;
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
color: #081319;
|
||
}
|
||
|
||
.bo-ac-l {
|
||
font-size: 12px;
|
||
color: #66635c;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
/* Section */
|
||
.bo-section {
|
||
overflow: hidden;
|
||
}
|
||
|
||
.bo-section-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 14px 16px 10px;
|
||
border-bottom: 1px solid #e8e3d6;
|
||
}
|
||
|
||
.bo-section-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #66635c;
|
||
letter-spacing: 0.005em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.bo-section-extra {
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: #66635c;
|
||
text-transform: none;
|
||
}
|
||
|
||
.bo-mgr-name {
|
||
font-weight: 500;
|
||
color: #081319;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.bo-num {
|
||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.bo-mono {
|
||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.bo-hint {
|
||
padding: 10px 16px 14px;
|
||
font-size: 11.5px;
|
||
color: #66635c;
|
||
line-height: 1.4;
|
||
}
|
||
</style>
|