Files
portal/app/resources/js/views/sales/SalesBossOverviewView.vue
T
Дмитрий 7cdbbdae46 feat(sales): сводка отдела (фронт)
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>
2026-07-02 21:07:10 +03:00

451 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 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>