Files
portal/app/resources/js/views/sales/SalesClientCardView.vue
T
Дмитрий dfb41751c8 feat(sales): показ дохода «Заработал» в списке/карточке/сводке (фронт)
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>
2026-07-02 19:50:24 +03:00

623 lines
25 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 1.4).
*
* Отрисовывает полную карточку клиента по данным GET /api/sales/clients/{tenantId}.
* Реализует #page-client-card из v8_sales.html с Vuetify + Forest-палитрой.
*
* Данные: getSalesClientCard(tenantId, salesPeriod.queryParams)
* При смене периода данные перегружаются (watch queryParams).
* 403 → inline-сообщение «Этот клиент не закреплён за вами».
*/
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { getSalesClientCard, type SalesClientCard } from '../../api/sales';
import { useSalesPeriodStore } from '../../stores/salesPeriod';
import HelpHint from '../../components/sales/HelpHint.vue';
// ─── props ────────────────────────────────────────────────────────────────────
// `props: true` in router passes route params as props
defineProps<{ id?: string }>();
const route = useRoute();
const router = useRouter();
const periodStore = useSalesPeriodStore();
// ─── state ────────────────────────────────────────────────────────────────────
const card = ref<SalesClientCard | null>(null);
const loading = ref(false);
const fetchError = ref<string | null>(null);
const is403 = 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 fmtMoneyDecimal(val: number | null | undefined): string {
if (val === null || val === undefined) return '—';
if (isNaN(val)) return '—';
return val.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ₽';
}
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yyyy = d.getFullYear();
return `${dd}.${mm}.${yyyy}`;
}
function fmtDateTime(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return `${dd}.${mm} ${hh}:${min}`;
}
function fmtActivity(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'только что';
if (diffMin < 60) return `${diffMin} мин назад`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `${diffH} ч назад`;
const diffD = Math.floor(diffH / 24);
if (diffD < 7) return `${diffD} дн назад`;
return fmtDate(iso);
}
function fmtDayLabel(dateStr: string): string {
// YYYY-MM-DD → DD.MM
const parts = dateStr.split('-');
if (parts.length !== 3) return dateStr;
return `${parts[2]}.${parts[1]}`;
}
// ─── subject_type labels ──────────────────────────────────────────────────────
const SUBJECT_TYPE_LABELS: Record<string, string> = {
individual: 'Физическое лицо',
sole_proprietor: 'ИП',
legal_entity: 'Юридическое лицо',
};
function subjectLabel(type: string | null | undefined): string {
if (!type) return '—';
return SUBJECT_TYPE_LABELS[type] ?? type;
}
// ─── signal_type labels ───────────────────────────────────────────────────────
const SIGNAL_LABELS: Record<string, string> = {
site: 'Сайт',
call: 'Звонок',
sms: 'SMS',
};
function signalLabel(type: string): string {
return SIGNAL_LABELS[type] ?? type;
}
// ─── project status ───────────────────────────────────────────────────────────
function projectStatusColor(status: string): string {
return status === 'active' ? 'success' : 'grey';
}
function projectStatusLabel(status: string): string {
return status === 'active' ? 'Активен' : 'Пауза';
}
// ─── region formatting ────────────────────────────────────────────────────────
function fmtRegions(regions: string[]): string {
if (!regions || regions.length === 0) return '—';
if (regions.length <= 2) return regions.join(', ');
return `${regions[0]} + ${regions.length - 1}`;
}
// ─── data loading ─────────────────────────────────────────────────────────────
async function load() {
const tenantId = route.params.id as string;
if (!tenantId) return;
loading.value = true;
fetchError.value = null;
is403.value = false;
try {
card.value = await getSalesClientCard(tenantId, periodStore.queryParams);
} catch (err: unknown) {
if (axios.isAxiosError(err) && err.response?.status === 403) {
is403.value = true;
} else {
fetchError.value = 'Не удалось загрузить данные клиента. Попробуйте обновить страницу.';
}
} finally {
loading.value = false;
}
}
// Reload when period changes
watch(() => periodStore.queryParams, load, { deep: true });
onMounted(load);
// ─── navigation ───────────────────────────────────────────────────────────────
function goBack() {
void router.push('/sales/clients');
}
defineExpose({ card, loading, fetchError, is403, load });
</script>
<template>
<v-container fluid class="scc-wrap pa-6">
<!-- Back link + Title -->
<div class="mb-4">
<div class="mb-2">
<a
class="scc-back"
role="button"
tabindex="0"
data-testid="back-link"
@click.prevent="goBack"
@keydown.enter.prevent="goBack"
>
Мои клиенты
</a>
</div>
<h1 class="scc-page-title" data-testid="org-name">
{{ card?.profile.organization_name ?? '…' }}
</h1>
</div>
<!-- Loading bar -->
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
<!-- 403 error -->
<v-alert v-if="is403" type="warning" variant="tonal" density="compact" class="mb-4" data-testid="error-403">
Этот клиент не закреплён за вами.
</v-alert>
<!-- Generic error -->
<v-alert
v-else-if="fetchError"
type="warning"
variant="tonal"
density="compact"
closable
class="mb-4"
data-testid="error-generic"
>
{{ fetchError }}
</v-alert>
<!-- Content only when card loaded -->
<template v-if="card">
<!-- KPI tiles -->
<div class="scc-kpi-row mb-6" data-testid="kpi-row">
<!-- Баланс -->
<div class="scc-kpi" data-testid="kpi-balance">
<div class="scc-k-label">Баланс клиента</div>
<div class="scc-k-val scc-accent scc-mono">{{ fmtMoney(card.kpi.balance_rub) }}</div>
<div class="scc-k-sub">
запас
{{
card.kpi.runway_days !== null && card.kpi.runway_days !== undefined
? `~${card.kpi.runway_days} дней`
: '—'
}}
<HelpHint text="На сколько дней хватит денег при текущем расходе на лиды" />
</div>
</div>
<!-- Проектов -->
<div class="scc-kpi" data-testid="kpi-projects">
<div class="scc-k-label">Проектов</div>
<div class="scc-k-val scc-mono">{{ card.kpi.projects_count }}</div>
<div class="scc-k-sub">
{{ card.projects.filter((p) => p.status === 'active').length }}
активных
</div>
</div>
<!-- Пришло / цель -->
<div class="scc-kpi" data-testid="kpi-leads">
<div class="scc-k-label">Пришло / цель (период)</div>
<div class="scc-k-val scc-mono">
{{ card.kpi.leads_delivered }}
<span class="scc-slash">/</span>
<span class="scc-accent">{{ card.kpi.leads_target }}</span>
</div>
<div class="scc-k-sub">доставлено за период</div>
</div>
<!-- Средняя цена лида -->
<div class="scc-kpi" data-testid="kpi-avg-price">
<div class="scc-k-label">
Средняя цена лида
<HelpHint text="Сколько в среднем стоит клиенту один лид за выбранный период" />
</div>
<div class="scc-k-val scc-mono">{{ fmtMoneyDecimal(card.kpi.avg_lead_price_rub) }}</div>
<div class="scc-k-sub">за период</div>
</div>
<!-- Заработано комиссия менеджера с этого клиента за период -->
<div class="scc-kpi" data-testid="kpi-earned">
<div class="scc-k-label">
Вы заработали (период)
<HelpHint
text="Ваша комиссия с этого клиента за период — по тарифу, закреплённому за клиентом"
/>
</div>
<div class="scc-k-val scc-mono" data-testid="earned-value">
{{ fmtMoneyDecimal(card.kpi.earned_rub) }}
</div>
<div class="scc-k-sub">по тарифу клиента</div>
</div>
</div>
<!-- Two-column layout -->
<div class="scc-cols">
<!-- LEFT column -->
<div class="scc-col-main">
<!-- Projects table -->
<v-card variant="outlined" class="mb-4">
<div class="scc-section-head px-4 pt-4 pb-2">
<h2 class="scc-h2">Проекты клиента</h2>
</div>
<v-table density="compact" data-testid="projects-table">
<thead>
<tr>
<th>Проект</th>
<th>Тип</th>
<th>Регион</th>
<th class="text-right scc-num">Лимит/день</th>
<th class="text-right scc-num">Пришло сегодня</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
<tr v-for="proj in card.projects" :key="proj.id" data-testid="project-row">
<td class="scc-proj-name">{{ proj.name }}</td>
<td>
<span class="scc-signal-badge">{{ signalLabel(proj.signal_type) }}</span>
</td>
<td class="text-medium-emphasis">{{ fmtRegions(proj.region) }}</td>
<td class="text-right scc-num scc-mono">{{ proj.daily_limit_target }}</td>
<td class="text-right scc-num scc-mono">{{ proj.delivered_today }}</td>
<td>
<v-chip :color="projectStatusColor(proj.status)" variant="tonal" size="x-small">
{{ projectStatusLabel(proj.status) }}
</v-chip>
</td>
</tr>
<tr v-if="card.projects.length === 0">
<td colspan="6" class="text-center text-medium-emphasis pa-4">Проектов нет</td>
</tr>
</tbody>
</v-table>
</v-card>
<!-- Leads by day -->
<v-card variant="outlined" class="mb-4">
<div class="scc-section-head px-4 pt-4 pb-2">
<h2 class="scc-h2">Лиды за последние дни</h2>
</div>
<v-table density="compact" data-testid="leads-by-day-table">
<thead>
<tr>
<th>Дата</th>
<th class="text-right scc-num">Пришло</th>
<th class="text-right scc-num">Потрачено клиентом</th>
</tr>
</thead>
<tbody>
<tr v-for="day in card.leads_by_day" :key="day.date" data-testid="leads-day-row">
<td class="scc-mono">{{ fmtDayLabel(day.date) }}</td>
<td class="text-right scc-num scc-mono">{{ day.count }}</td>
<td class="text-right scc-num scc-mono">{{ fmtMoney(day.oborot_rub) }}</td>
</tr>
<tr v-if="card.leads_by_day.length === 0">
<td colspan="3" class="text-center text-medium-emphasis pa-4">Нет данных</td>
</tr>
</tbody>
</v-table>
</v-card>
<!-- Recent leads -->
<v-card variant="outlined">
<div class="scc-section-head px-4 pt-4 pb-2">
<h2 class="scc-h2">Последние лиды</h2>
</div>
<v-table density="compact" data-testid="recent-leads-table">
<thead>
<tr>
<th>Время</th>
<th>Телефон</th>
<th>Регион</th>
<th>Источник</th>
<th>Проект</th>
</tr>
</thead>
<tbody>
<tr v-for="(lead, idx) in card.recent_leads" :key="idx" data-testid="recent-lead-row">
<td class="scc-mono">{{ fmtDateTime(lead.received_at) }}</td>
<td class="scc-mono" data-testid="lead-phone">{{ lead.phone_masked }}</td>
<td>{{ lead.region }}</td>
<td>{{ lead.source }}</td>
<td>{{ lead.project }}</td>
</tr>
<tr v-if="card.recent_leads.length === 0">
<td colspan="5" class="text-center text-medium-emphasis pa-4">Лидов нет</td>
</tr>
</tbody>
</v-table>
</v-card>
</div>
<!-- RIGHT column (aside) -->
<div class="scc-col-aside">
<!-- Profile -->
<v-card variant="outlined" class="mb-4">
<div class="scc-section-head px-4 pt-4 pb-2">
<h2 class="scc-h2">Профиль клиента</h2>
</div>
<div class="scc-kv-grid px-4 pb-4">
<span class="scc-k">Логин (e-mail)</span>
<span class="scc-v scc-mono">{{ card.profile.contact_email ?? '—' }}</span>
<span class="scc-k">Контактное лицо</span>
<span class="scc-v">{{ card.profile.contact_name ?? '—' }}</span>
<span class="scc-k">Телефон</span>
<span class="scc-v scc-mono">{{ card.profile.contact_phone ?? '—' }}</span>
<span class="scc-k">Тип лица</span>
<span class="scc-v">{{ subjectLabel(card.profile.subject_type) }}</span>
<span class="scc-k">ИНН</span>
<span class="scc-v scc-mono">{{ card.profile.inn ?? '—' }}</span>
<span class="scc-k">Клиент с</span>
<span class="scc-v scc-mono">{{ fmtDate(card.profile.created_at) }}</span>
<span class="scc-k">Желаемый объём</span>
<span class="scc-v">
{{
card.profile.desired_daily_numbers !== null &&
card.profile.desired_daily_numbers !== undefined
? `~${card.profile.desired_daily_numbers} лидов/день`
: '—'
}}
</span>
<span class="scc-k">Последняя активность</span>
<span class="scc-v">{{ fmtActivity(card.profile.last_activity_at) }}</span>
</div>
</v-card>
<!-- Activity -->
<v-card variant="outlined">
<div class="scc-section-head px-4 pt-4 pb-2">
<h2 class="scc-h2">Активность</h2>
</div>
<div class="scc-timeline px-4 pb-4">
<div
v-for="(item, idx) in card.activity"
:key="idx"
class="scc-tm-item"
data-testid="activity-row"
>
<div class="scc-tm-when scc-mono">{{ fmtDateTime(item.created_at) }}</div>
<div class="scc-tm-text">{{ item.description }}</div>
</div>
<div v-if="card.activity.length === 0" class="text-medium-emphasis py-2">
Нет активности
</div>
</div>
</v-card>
</div>
</div>
</template>
</v-container>
</template>
<style scoped>
.scc-wrap {
max-width: 1400px;
}
/* ─── Back link ─────────────────────────────────────────── */
.scc-back {
font-size: 13px;
color: #0f6e56;
cursor: pointer;
text-decoration: none;
font-weight: 500;
}
.scc-back:hover {
text-decoration: underline;
}
/* ─── Title ─────────────────────────────────────────────── */
.scc-page-title {
font-size: 1.5rem;
font-weight: 700;
color: #081319;
letter-spacing: -0.02em;
}
/* ─── KPI row ───────────────────────────────────────────── */
.scc-kpi-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
@media (max-width: 1200px) {
.scc-kpi-row {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 700px) {
.scc-kpi-row {
grid-template-columns: repeat(2, 1fr);
}
}
.scc-kpi {
background: #fffdfa;
border: 1px solid #d9d5cd;
border-radius: 10px;
padding: 14px 16px;
}
.scc-k-label {
font-size: 11.5px;
color: #66635c;
font-weight: 500;
margin-bottom: 4px;
}
.scc-k-val {
font-size: 1.35rem;
font-weight: 700;
color: #081319;
line-height: 1.2;
margin-bottom: 4px;
}
.scc-k-sub {
font-size: 11.5px;
color: #66635c;
}
.scc-accent {
color: #0f6e56 !important;
}
.scc-slash {
color: #92907b;
font-size: 1rem;
font-weight: 400;
}
/* ─── Two-column layout ─────────────────────────────────── */
.scc-cols {
display: grid;
grid-template-columns: 1fr 340px;
gap: 20px;
align-items: start;
}
@media (max-width: 960px) {
.scc-cols {
grid-template-columns: 1fr;
}
}
/* ─── Section heading ───────────────────────────────────── */
.scc-h2 {
font-size: 14px;
font-weight: 600;
color: #081319;
letter-spacing: -0.01em;
margin: 0;
}
/* ─── Tables ────────────────────────────────────────────── */
.scc-num {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
}
.scc-mono {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
font-size: 12.5px;
}
.scc-proj-name {
font-weight: 500;
color: #081319;
}
.scc-signal-badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
padding: 2px 7px;
border-radius: 4px;
background: #e1eeea;
color: #084635;
white-space: nowrap;
}
/* ─── Profile kv grid ───────────────────────────────────── */
.scc-kv-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 12px;
align-items: baseline;
}
.scc-k {
font-size: 12px;
color: #66635c;
white-space: nowrap;
font-weight: 500;
}
.scc-v {
font-size: 13px;
color: #081319;
word-break: break-word;
}
/* ─── Activity timeline ─────────────────────────────────── */
.scc-timeline {
display: flex;
flex-direction: column;
gap: 12px;
}
.scc-tm-item {
display: flex;
flex-direction: column;
gap: 2px;
padding-left: 12px;
border-left: 2px solid #e1eeea;
}
.scc-tm-when {
font-size: 11px;
color: #66635c;
}
.scc-tm-text {
font-size: 13px;
color: #081319;
}
</style>