Files
portal/app/resources/js/views/sales/SalesPerformanceView.vue
T
Дмитрий 709478230f feat(sales): результативность менеджеров (фронт)
Task 6.2b — SalesPerformanceView (начальник, #page-performance) поверх
GET /api/sales/managers/performance.

- Поиск по имени (debounce) + таблица: менеджер (имя/email), клиентов,
  активных, лидов пришло, оборот, выплачено, заработал, статус
  (Активен/Отпуск). HelpHint на Оборот/Заработал. Период из salesPeriod store.
- Роут /sales/performance со заглушки на экран.

Vitest 5/5, фронт-набор 1083 без регрессий, ESLint чист.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:23:46 +03:00

250 lines
9.4 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.2b, только начальник).
*
* Таблица по каждому менеджеру. Реализует #page-performance из v8_sales.html.
* Данные: GET /api/sales/managers/performance?period=...&search=...
* Период берётся из salesPeriod store (PeriodPicker встроен в SalesLayout topbar).
* При смене периода таблица перегружается автоматически (watch queryParams).
* Поиск по имени менеджера шлётся на бэк параметром `search` с debounce ~400ms.
*/
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
import { getSalesManagersPerformance, type SalesPerformanceRow } from '../../api/sales';
import { useSalesPeriodStore } from '../../stores/salesPeriod';
import HelpHint from '../../components/sales/HelpHint.vue';
const periodStore = useSalesPeriodStore();
const rows = ref<SalesPerformanceRow[]>([]);
const loading = ref(false);
const fetchError = ref(false);
const search = ref('');
// ─── status chips ─────────────────────────────────────────────────────────────
interface StatusMeta {
label: string;
color: string;
variant: 'tonal' | 'flat';
}
const STATUS_META: Record<string, StatusMeta> = {
active: { label: 'Активен', color: 'success', variant: 'tonal' },
vacation: { label: 'Отпуск', color: 'blue-grey', variant: 'tonal' },
};
function statusMeta(s: string): StatusMeta {
return STATUS_META[s] ?? { label: s, color: 'grey', variant: 'tonal' };
}
// ─── formatters ───────────────────────────────────────────────────────────────
function fmtMoney(val: number): string {
if (val === null || val === undefined || isNaN(val)) return '—';
return val.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' ₽';
}
function fmtNum(val: number): string {
if (val === null || val === undefined || isNaN(val)) return '—';
return val.toLocaleString('ru-RU');
}
// ─── data loading ─────────────────────────────────────────────────────────────
async function load() {
loading.value = true;
fetchError.value = false;
try {
const params = {
...periodStore.queryParams,
...(search.value ? { search: search.value } : {}),
};
rows.value = await getSalesManagersPerformance(params);
} catch {
fetchError.value = true;
} finally {
loading.value = false;
}
}
// Reload when period changes.
watch(() => periodStore.queryParams, load, { deep: true });
// Search: перезагрузка с debounce ~400ms.
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(search, () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
void load();
}, 400);
});
onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer);
});
onMounted(load);
defineExpose({ rows, loading, fetchError, search, load });
</script>
<template>
<v-container fluid class="sales-performance pa-6">
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-4 flex-wrap ga-3">
<div>
<h1 class="text-h5 font-weight-bold sp-page-title">Результативность менеджеров</h1>
<div class="text-medium-emphasis sp-subtitle">как работает каждый менеджер</div>
</div>
</div>
<!-- Search bar -->
<div class="d-flex align-center ga-3 mb-4 flex-wrap">
<v-text-field
v-model="search"
density="compact"
variant="outlined"
hide-details
placeholder="Имя менеджера…"
prepend-inner-icon="mdi-magnify"
style="max-width: 320px"
data-testid="search-input"
/>
</div>
<!-- Error alert -->
<v-alert v-if="fetchError" type="warning" variant="tonal" density="compact" closable class="mb-4">
Не удалось загрузить данные. Попробуйте обновить страницу.
</v-alert>
<!-- Loading -->
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-2" />
<!-- Table -->
<v-card variant="outlined">
<v-table density="compact" data-testid="perf-table">
<thead>
<tr>
<th>Менеджер</th>
<th class="text-right sp-num">Клиентов</th>
<th class="text-right sp-num">Активных</th>
<th class="text-right sp-num">Лидов пришло</th>
<th class="text-right sp-num">
Оборот клиентов
<HelpHint text="Сколько потратили на лиды все клиенты этого менеджера за период" />
</th>
<th class="text-right sp-num">Выплачено </th>
<th class="text-right sp-num">
Заработал (комиссия)
<HelpHint text="Комиссия менеджера — сумма по тарифам всех его клиентов" />
</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.manager_id" data-testid="perf-row">
<!-- Менеджер -->
<td class="sp-name-cell">
<span class="sp-name">{{ row.name }}</span>
<span class="sp-email">{{ row.email }}</span>
</td>
<!-- Клиентов -->
<td class="text-right sp-num sp-mono">{{ fmtNum(row.clients_count) }}</td>
<!-- Активных -->
<td class="text-right sp-num sp-mono">{{ fmtNum(row.active_clients) }}</td>
<!-- Лидов пришло -->
<td class="text-right sp-num sp-mono">{{ fmtNum(row.leads_delivered) }}</td>
<!-- Оборот клиентов -->
<td class="text-right sp-num sp-mono">{{ fmtMoney(row.oborot_rub) }}</td>
<!-- Выплачено -->
<td class="text-right sp-num sp-mono">{{ fmtMoney(row.paid_all_time_rub) }}</td>
<!-- Заработал (комиссия) -->
<td class="text-right sp-num sp-mono" data-testid="earned-cell">
{{ fmtMoney(row.earned_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>
</tr>
<!-- Empty state -->
<tr v-if="rows.length === 0 && !loading">
<td colspan="8" class="text-center text-medium-emphasis pa-6">Менеджеры не найдены</td>
</tr>
</tbody>
</v-table>
</v-card>
<!-- Foot note -->
<div class="sp-foot-note mt-3">
«Оборот клиентов» реальные деньги клиентов менеджера. «Заработал (комиссия)» считается по тарифу менеджера
настраивается в разделе «Тарифы менеджеров».
</div>
</v-container>
</template>
<style scoped>
.sales-performance {
max-width: 1500px;
}
.sp-page-title {
color: #081319;
letter-spacing: -0.02em;
}
.sp-subtitle {
font-size: 13px;
margin-top: 2px;
}
.sp-name-cell {
min-width: 200px;
}
.sp-name {
display: block;
font-weight: 500;
color: #081319;
font-size: 13px;
}
.sp-email {
display: block;
font-size: 11px;
color: #66635c;
margin-top: 1px;
}
.sp-num {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
}
.sp-mono {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
}
.sp-foot-note {
font-size: 12px;
color: #66635c;
max-width: 1040px;
}
</style>