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>
148 lines
7.3 KiB
Vue
148 lines
7.3 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Админка → Карточка лида (L4) — конечный источник: ОТКУДА пришёл лид
|
||
* (поставщик-проект + канал + регион) → КОМУ ушёл (сделки клиентов).
|
||
* Завершает сквозную вложенность дашборда (плитка Лиды → список → сюда).
|
||
*/
|
||
import { onMounted, ref } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import { getLead, type LeadDetail } from '../../api/adminLeads';
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
|
||
const detail = ref<LeadDetail | null>(null);
|
||
const loading = ref(false);
|
||
const fetchError = ref(false);
|
||
|
||
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 | null): string {
|
||
if (!v) return '—';
|
||
const m = v.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}:\d{2})/);
|
||
return m ? `${m[3]}.${m[2]}.${m[1]} ${m[4]}` : v;
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
fetchError.value = false;
|
||
try {
|
||
detail.value = await getLead(route.params.id as string);
|
||
} catch {
|
||
fetchError.value = true;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
function openTenant(subdomain: string) {
|
||
router.push({ name: 'admin-tenant-detail', params: { code: subdomain } });
|
||
}
|
||
|
||
onMounted(load);
|
||
defineExpose({ detail, loading, fetchError, load });
|
||
</script>
|
||
|
||
<template>
|
||
<v-container fluid class="lead-detail pa-6">
|
||
<div class="d-flex align-center justify-space-between mb-3 flex-wrap ga-3">
|
||
<h1 class="text-h5 font-weight-bold">Лид #{{ route.params.id }}</h1>
|
||
<v-btn variant="text" class="text-none" prepend-icon="mdi-arrow-left" to="/admin/leads">Все лиды</v-btn>
|
||
</div>
|
||
|
||
<v-alert v-if="fetchError" type="warning" variant="tonal" density="compact" class="mb-4">
|
||
Не удалось загрузить лид.
|
||
</v-alert>
|
||
|
||
<template v-if="detail">
|
||
<v-row>
|
||
<!-- ОТКУДА -->
|
||
<v-col cols="12" md="6">
|
||
<v-card variant="outlined" class="h-100" data-testid="lead-source">
|
||
<v-card-title class="card-h">📥 Откуда пришёл</v-card-title>
|
||
<v-card-text>
|
||
<div class="kv"><span>Поставщик</span><b>{{ detail.source.platform }}</b></div>
|
||
<div class="kv"><span>Канал</span><b>{{ channelLabel(detail.source.channel) }}</b></div>
|
||
<div class="kv"><span>Источник</span><b>{{ detail.source.identifier ?? '—' }}</b></div>
|
||
<div class="kv"><span>Регион (код РФ)</span><b>{{ detail.lead.region_code ?? '—' }}</b></div>
|
||
<div class="kv"><span>Оператор</span><b>{{ detail.lead.phone_operator ?? '—' }}</b></div>
|
||
<div class="kv"><span>Телефон</span><b class="num">{{ detail.lead.phone_masked }}</b></div>
|
||
<v-btn
|
||
v-if="detail.source.supplier_project_id"
|
||
variant="text" size="small" class="text-none mt-2 px-0"
|
||
to="/admin/supplier-projects"
|
||
>
|
||
Открыть в «Проектах у поставщика» →
|
||
</v-btn>
|
||
</v-card-text>
|
||
</v-card>
|
||
</v-col>
|
||
|
||
<!-- ЧТО / СТАТУС -->
|
||
<v-col cols="12" md="6">
|
||
<v-card variant="outlined" class="h-100">
|
||
<v-card-title class="card-h">ℹ️ Лид</v-card-title>
|
||
<v-card-text>
|
||
<div class="kv"><span>Получен</span><b class="num">{{ fmtDate(detail.lead.received_at) }}</b></div>
|
||
<div class="kv"><span>Обработан</span><b class="num">{{ fmtDate(detail.lead.processed_at) }}</b></div>
|
||
<div class="kv">
|
||
<span>Статус</span>
|
||
<v-chip :color="statusMeta(detail.lead.status).color" size="x-small" variant="tonal">
|
||
{{ statusMeta(detail.lead.status).label }}
|
||
</v-chip>
|
||
</div>
|
||
<div class="kv"><span>Создано сделок</span><b>{{ detail.lead.deals_created_count }}</b></div>
|
||
<div v-if="detail.lead.error" class="kv">
|
||
<span>Ошибка</span><b class="text-error">{{ detail.lead.error }}</b>
|
||
</div>
|
||
</v-card-text>
|
||
</v-card>
|
||
</v-col>
|
||
</v-row>
|
||
|
||
<!-- КОМУ -->
|
||
<v-card variant="outlined" class="mt-4" data-testid="lead-deals">
|
||
<v-card-title class="card-h">📤 Кому ушёл — сделки клиентов</v-card-title>
|
||
<v-card-text>
|
||
<v-table density="compact">
|
||
<thead>
|
||
<tr><th>Клиент</th><th>Статус сделки</th><th>Получена</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="d in detail.deals" :key="d.id" class="clk" @click="openTenant(d.subdomain)">
|
||
<td>{{ d.tenant_name }}</td>
|
||
<td>{{ d.status }}</td>
|
||
<td class="num">{{ fmtDate(d.received_at) }}</td>
|
||
</tr>
|
||
<tr v-if="detail.deals.length === 0">
|
||
<td colspan="3" class="text-center text-medium-emphasis">
|
||
Сделок по этому лиду нет (не распределён или нет совпадений у клиентов).
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</v-table>
|
||
</v-card-text>
|
||
</v-card>
|
||
</template>
|
||
</v-container>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.lead-detail { max-width: 1100px; }
|
||
.card-h { font-size: 15px; font-weight: 700; }
|
||
.kv { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid rgba(0,0,0,0.05); }
|
||
.kv span { color: rgba(0,0,0,0.6); }
|
||
.num { font-family: 'JetBrains Mono', 'Consolas', monospace; font-variant-numeric: tabular-nums; }
|
||
.clk:hover { background: rgba(15, 110, 86, 0.06); cursor: pointer; }
|
||
</style>
|