Files
portal/app/resources/js/views/admin/AdminLeadsView.vue
T
Дмитрий 6536c19c96 feat(дашборд): Этап A — сквозная вложенность Лиды до источника
Экран «Лиды» (/admin/leads): серверный список с фильтрами (дата/канал/поставщик/
статус/поиск) + пагинация (масштаб 10⁴+ лидов). Карточка лида (/admin/leads/{id}):
полная цепочка — ОТКУДА (поставщик B1/B2/B3 + канал + источник + регион) → КОМУ
(сделки клиентов через deals.source_crm_id = supplier_leads.vid). Дашборд: drill
Лиды +топ-10 последних + «Открыть все лиды →». Nav-пункт «Лиды». ПДн-телефон
маскируется (152-ФЗ). Тесты: backend 3 + FE 5 (38 FE всего зелёные).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:14:47 +03:00

195 lines
7.9 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">
/**
* Админка → Лиды (L3). Полный список лидов с серверными фильтрами/пагинацией
* (масштаб: десятки тысяч лидов). Клик по строке → карточка лида (L4, цепочка).
* Сюда ведёт «Открыть все лиды →» из дашборда (плитка Лиды).
*/
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getLeads, type LeadRow, type LeadsFilters } from '../../api/adminLeads';
const router = useRouter();
const route = useRoute();
const rows = ref<LeadRow[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = ref(25);
const loading = ref(false);
const fetchError = ref(false);
const filters = ref<LeadsFilters>({
date_from: '',
date_to: '',
channel: (route.query.channel as string) || '',
platform: '',
status: '',
search: '',
});
const CHANNELS = [
{ value: '', title: 'Все каналы' },
{ value: 'site', title: 'Сайт' },
{ value: 'call', title: 'Звонок' },
{ value: 'sms', title: 'SMS' },
];
const PLATFORMS = [
{ value: '', title: 'Все поставщики' },
{ value: 'B1', title: 'B1' },
{ value: 'B2', title: 'B2' },
{ value: 'B3', title: 'B3' },
{ value: 'DIRECT', title: 'Напрямую' },
];
const STATUSES = [
{ value: '', title: 'Любой статус' },
{ value: 'delivered', title: 'Доставлен' },
{ value: 'no_match', title: 'Без получателя' },
{ value: 'stuck', title: 'Завис' },
{ value: 'pending', title: 'В обработке' },
{ value: 'error', title: 'Ошибка' },
];
const STATUS_META: Record<string, { label: string; color: string }> = {
delivered: { label: 'доставлен', color: 'success' },
no_match: { label: 'без получателя', color: 'warning' },
stuck: { label: 'завис', color: 'error' },
pending: { label: 'в обработке', color: 'info' },
error: { label: 'ошибка', color: 'error' },
};
function statusMeta(s: string) {
return STATUS_META[s] ?? { label: s, color: 'grey' };
}
function channelLabel(c: string | null): string {
return c === 'site' ? 'Сайт' : c === 'call' ? 'Звонок' : c === 'sms' ? 'SMS' : '—';
}
function fmtDate(v: string): string {
const m = v.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}:\d{2})/);
return m ? `${m[3]}.${m[2]} ${m[4]}` : v;
}
const totalPages = () => Math.max(1, Math.ceil(total.value / perPage.value));
async function load() {
loading.value = true;
fetchError.value = false;
try {
const res = await getLeads({ ...filters.value, page: page.value, per_page: perPage.value });
rows.value = res.data;
total.value = res.total;
} catch {
fetchError.value = true;
} finally {
loading.value = false;
}
}
function applyFilters() {
page.value = 1;
void load();
}
function goPage(p: number) {
page.value = p;
void load();
}
function openLead(id: number) {
router.push({ name: 'admin-lead-detail', params: { id: String(id) } });
}
onMounted(load);
defineExpose({ rows, total, page, perPage, filters, loading, fetchError, load, applyFilters });
</script>
<template>
<v-container fluid class="admin-leads pa-6">
<div class="d-flex align-center justify-space-between mb-3 flex-wrap ga-3">
<h1 class="text-h5 font-weight-bold">Лиды</h1>
<v-btn variant="text" class="text-none" prepend-icon="mdi-view-dashboard-outline" to="/admin/dashboard">
Командный центр
</v-btn>
</div>
<!-- Фильтры -->
<v-card variant="outlined" class="mb-4">
<v-card-text class="d-flex flex-wrap align-center ga-3">
<v-text-field
v-model="filters.date_from" type="date" label="С" density="compact" variant="outlined"
hide-details style="max-width: 160px" data-testid="f-date-from" />
<v-text-field
v-model="filters.date_to" type="date" label="По" density="compact" variant="outlined"
hide-details style="max-width: 160px" data-testid="f-date-to" />
<v-select
v-model="filters.channel" :items="CHANNELS" label="Канал" density="compact" variant="outlined"
hide-details style="max-width: 160px" data-testid="f-channel" />
<v-select
v-model="filters.platform" :items="PLATFORMS" label="Поставщик" density="compact" variant="outlined"
hide-details style="max-width: 170px" data-testid="f-platform" />
<v-select
v-model="filters.status" :items="STATUSES" label="Статус" density="compact" variant="outlined"
hide-details style="max-width: 180px" data-testid="f-status" />
<v-text-field
v-model="filters.search" label="Поиск (телефон / источник)" density="compact" variant="outlined"
hide-details style="max-width: 240px" data-testid="f-search" @keyup.enter="applyFilters" />
<v-btn color="primary" class="text-none" data-testid="apply-filters" @click="applyFilters">Найти</v-btn>
</v-card-text>
</v-card>
<v-alert v-if="fetchError" type="warning" variant="tonal" density="compact" closable class="mb-4">
Не удалось загрузить лиды. Попробуйте обновить.
</v-alert>
<v-card variant="outlined">
<v-table density="compact">
<thead>
<tr>
<th>Время</th>
<th>Канал</th>
<th>Источник</th>
<th>Поставщик</th>
<th>Регион</th>
<th>Телефон</th>
<th class="text-right">Клиентов</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
<tr v-for="l in rows" :key="l.id" class="clk" @click="openLead(l.id)">
<td class="num">{{ fmtDate(l.received_at) }}</td>
<td>{{ channelLabel(l.channel) }}</td>
<td>{{ l.source ?? '—' }}</td>
<td>{{ l.platform }}</td>
<td class="num">{{ l.region_code ?? '—' }}</td>
<td class="num">{{ l.phone_masked }}</td>
<td class="text-right num">{{ l.deals_created_count }}</td>
<td>
<v-chip :color="statusMeta(l.status).color" size="x-small" variant="tonal">
{{ statusMeta(l.status).label }}
</v-chip>
</td>
</tr>
<tr v-if="rows.length === 0 && !loading">
<td colspan="8" class="text-center text-medium-emphasis">Лидов по фильтрам не найдено</td>
</tr>
</tbody>
</v-table>
</v-card>
<div class="d-flex align-center justify-space-between mt-3 flex-wrap ga-2">
<span class="text-medium-emphasis text-body-2">Всего: {{ total }}</span>
<v-pagination
v-model="page"
:length="totalPages()"
:total-visible="7"
density="compact"
data-testid="pager"
@update:model-value="goPage"
/>
</div>
</v-container>
</template>
<style scoped>
.admin-leads { max-width: 1280px; }
.num { font-family: 'JetBrains Mono', 'Consolas', monospace; font-variant-numeric: tabular-nums; }
.clk:hover { background: rgba(15, 110, 86, 0.06); cursor: pointer; }
</style>