Files
portal/app/resources/js/views/admin/AdminLeadDetailView.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

148 lines
7.3 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">
/**
* Админка → Карточка лида (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>