Files
portal/app/resources/js/views/admin/AdminLeadsView.vue
T

195 lines
7.9 KiB
Vue
Raw Normal View History

<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>