6536c19c96
Экран «Лиды» (/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>
195 lines
7.9 KiB
Vue
195 lines
7.9 KiB
Vue
<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>
|