709478230f
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>
250 lines
9.4 KiB
Vue
250 lines
9.4 KiB
Vue
<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>
|