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>
This commit is contained in:
Дмитрий
2026-06-28 10:14:47 +03:00
parent 5c68b24c7b
commit 6536c19c96
15 changed files with 918 additions and 9 deletions
@@ -279,11 +279,29 @@ class AdminDashboardController extends Controller
];
}
/** GET /api/admin/dashboard/leads — KPI распределения лидов (L2). */
/** GET /api/admin/dashboard/leads — KPI распределения лидов + топ-10 последних (L2). */
public function leads(): JsonResponse
{
$m = $this->leadsMetrics();
// Топ-10 последних лидов для drill (полный список — на экране /admin/leads).
$recent = DB::table('supplier_leads as sl')
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id')
->orderByDesc('sl.received_at')
->limit(10)
->get(['sl.id', 'sl.received_at', 'sl.platform', 'sl.phone', 'sl.processed_at',
'sl.deals_created_count', 'sp.signal_type as channel', 'sp.unique_key'])
->map(fn ($r) => [
'id' => (int) $r->id,
'received_at' => $r->received_at,
'platform' => $r->platform,
'channel' => $r->channel,
'source' => $r->unique_key,
'phone_masked' => $this->maskPhoneShort($r->phone),
'delivered' => ((int) ($r->deals_created_count ?? 0)) > 0,
'processed' => $r->processed_at !== null,
]);
return response()->json([
'light' => $m['light'],
'kpi' => [
@@ -292,9 +310,21 @@ class AdminDashboardController extends Controller
'stuck' => $m['stuck'],
'unrouted' => $m['unrouted'],
],
'recent' => $recent,
]);
}
/** Короткая маска телефона для drill (152-ФЗ). */
private function maskPhoneShort(?string $phone): string
{
if (! $phone) {
return '—';
}
$d = preg_replace('/\D/', '', $phone);
return strlen((string) $d) >= 4 ? substr((string) $d, 0, 2).'***'.substr((string) $d, -2) : '***';
}
// === Этап 2: Заказ у поставщика ===
/**
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin «Лиды» (L3) сквозная вложенность дашборда до конечного источника.
* Серверная пагинация/фильтры (масштаб: десятки тысяч лидов).
* Цепочка: supplier_leads.supplier_project_id источник (канал+identifier),
* platform = поставщик (B1/B2/B3), resolved_subject_code = регион,
* deals.source_crm_id = supplier_leads.vid сделки клиентов.
* Группа ['saas-admin','admin-db'] cross-tenant через pgsql_admin.
* Spec: docs/superpowers/specs/2026-06-28-dashboard-drilldown-scale-design.md
*/
class AdminLeadsController extends Controller
{
private const PER_PAGE_DEFAULT = 25;
private const PER_PAGE_MAX = 100;
private const STUCK_HOURS = 4;
/** Маска телефона по 152-ФЗ: «+7 9** *** ** 07» (видны код страны и 2 последние). */
private function maskPhone(?string $phone): string
{
if (! $phone) {
return '—';
}
$digits = preg_replace('/\D/', '', $phone);
if (strlen((string) $digits) < 4) {
return '***';
}
$last2 = substr((string) $digits, -2);
$first = substr((string) $digits, 0, 2);
return $first.'** *** ** '.$last2;
}
/** Производный статус лида для UI. */
private function statusOf(object $r): string
{
if ($r->error !== null && $r->error !== '') {
return 'error';
}
if ($r->processed_at !== null) {
return ((int) ($r->deals_created_count ?? 0)) > 0 ? 'delivered' : 'no_match';
}
return 'pending'; // визуально «завис» определяет фронт по времени, но базово pending
}
/** Базовый запрос лидов с присоединённым источником (supplier_projects). */
private function baseQuery(Request $request): Builder
{
$q = DB::table('supplier_leads as sl')
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id');
if (($df = (string) $request->query('date_from', '')) !== '' && ($dt = (string) $request->query('date_to', '')) !== '') {
$q->whereBetween('sl.received_at', [$df.' 00:00:00', $dt.' 23:59:59']);
}
if (($channel = (string) $request->query('channel', '')) !== '') {
$q->where('sp.signal_type', $channel);
}
if (($platform = (string) $request->query('platform', '')) !== '') {
$q->where('sl.platform', $platform);
}
if (($search = trim((string) $request->query('search', ''))) !== '') {
$q->where(function ($w) use ($search) {
$w->where('sl.phone', 'like', '%'.$search.'%')
->orWhere('sp.unique_key', 'like', '%'.$search.'%')
->orWhere('sl.vid', '=', ctype_digit($search) ? (int) $search : 0);
});
}
if (($status = (string) $request->query('status', '')) !== '') {
$this->applyStatusFilter($q, $status);
}
if (($tenantId = (int) $request->query('tenant_id', 0)) > 0) {
$q->whereExists(function ($e) use ($tenantId) {
$e->select(DB::raw(1))->from('deals')
->whereColumn('deals.source_crm_id', 'sl.vid')
->where('deals.tenant_id', $tenantId);
});
}
return $q;
}
private function applyStatusFilter(Builder $q, string $status): void
{
match ($status) {
'error' => $q->whereNotNull('sl.error')->where('sl.error', '<>', ''),
'delivered' => $q->whereNotNull('sl.processed_at')->where('sl.deals_created_count', '>', 0),
'no_match' => $q->whereNotNull('sl.processed_at')
->where(fn ($w) => $w->whereNull('sl.deals_created_count')->orWhere('sl.deals_created_count', '=', 0)),
'stuck' => $q->whereNull('sl.processed_at')->where('sl.received_at', '<', now()->subHours(self::STUCK_HOURS)),
'pending' => $q->whereNull('sl.processed_at'),
default => null,
};
}
/** @return array<string,mixed> */
private function rowToArray(object $r): array
{
return [
'id' => (int) $r->id,
'received_at' => $r->received_at,
'platform' => $r->platform,
'channel' => $r->channel,
'source' => $r->unique_key,
'region_code' => $r->resolved_subject_code !== null ? (int) $r->resolved_subject_code : null,
'phone_masked' => $this->maskPhone($r->phone),
'deals_created_count' => (int) ($r->deals_created_count ?? 0),
'status' => $this->statusOf($r),
];
}
/** GET /api/admin/leads — серверный список с фильтрами/пагинацией. */
public function index(Request $request): JsonResponse
{
$perPage = min(self::PER_PAGE_MAX, max(1, (int) $request->query('per_page', self::PER_PAGE_DEFAULT)));
$page = max(1, (int) $request->query('page', 1));
$base = $this->baseQuery($request);
$total = (clone $base)->count();
$rows = $base
->orderByDesc('sl.received_at')
->offset(($page - 1) * $perPage)
->limit($perPage)
->get([
'sl.id', 'sl.received_at', 'sl.platform', 'sl.phone', 'sl.deals_created_count',
'sl.processed_at', 'sl.error', 'sl.resolved_subject_code',
'sp.signal_type as channel', 'sp.unique_key',
])
->map(fn ($r) => $this->rowToArray($r));
return response()->json([
'data' => $rows,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
]);
}
/** GET /api/admin/leads/{id} — карточка лида: источник + сделки клиентов (цепочка). */
public function show(int $id): JsonResponse
{
$lead = DB::table('supplier_leads as sl')
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id')
->where('sl.id', $id)
->first([
'sl.id', 'sl.received_at', 'sl.processed_at', 'sl.error', 'sl.platform', 'sl.phone',
'sl.vid', 'sl.deals_created_count', 'sl.resolved_subject_code', 'sl.region_source',
'sl.phone_operator', 'sp.signal_type as channel', 'sp.unique_key', 'sp.id as supplier_project_id',
]);
if ($lead === null) {
return response()->json(['message' => 'Лид не найден'], 404);
}
$deals = DB::table('deals')
->join('tenants', 'tenants.id', '=', 'deals.tenant_id')
->where('deals.source_crm_id', $lead->vid)
->orderByDesc('deals.received_at')
->limit(50)
->get([
'deals.id', 'deals.tenant_id', 'tenants.organization_name', 'tenants.subdomain',
'deals.status', 'deals.project_id', 'deals.received_at',
])
->map(fn ($d) => [
'id' => (int) $d->id,
'tenant_id' => (int) $d->tenant_id,
'tenant_name' => $d->organization_name ?: $d->subdomain,
'subdomain' => $d->subdomain,
'status' => $d->status,
'project_id' => $d->project_id !== null ? (int) $d->project_id : null,
'received_at' => $d->received_at,
]);
return response()->json([
'lead' => [
'id' => (int) $lead->id,
'platform' => $lead->platform,
'phone_masked' => $this->maskPhone($lead->phone),
'received_at' => $lead->received_at,
'processed_at' => $lead->processed_at,
'error' => $lead->error,
'region_code' => $lead->resolved_subject_code !== null ? (int) $lead->resolved_subject_code : null,
'region_source' => $lead->region_source,
'phone_operator' => $lead->phone_operator,
'deals_created_count' => (int) ($lead->deals_created_count ?? 0),
'status' => $this->statusOf($lead),
],
'source' => [
'platform' => $lead->platform,
'channel' => $lead->channel,
'identifier' => $lead->unique_key,
'supplier_project_id' => $lead->supplier_project_id !== null ? (int) $lead->supplier_project_id : null,
],
'deals' => $deals,
]);
}
}
+6
View File
@@ -336,6 +336,12 @@ parameters:
count: 1
path: tests/Feature/Admin/AdminDashboardSupplyTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/Admin/AdminLeadsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
+10
View File
@@ -111,6 +111,16 @@ export interface LeadsDetail {
stuck: number;
unrouted: number;
};
recent: Array<{
id: number;
received_at: string;
platform: string;
channel: string | null;
source: string | null;
phone_masked: string;
delivered: boolean;
processed: boolean;
}>;
}
export interface SupplyDetail {
+81
View File
@@ -0,0 +1,81 @@
import { apiClient } from './client';
/**
* SaaS-admin «Лиды» (L3) — сквозная вложенность дашборда до источника.
* Серверная пагинация/фильтры. Backend: AdminLeadsController.
* Spec: docs/superpowers/specs/2026-06-28-dashboard-drilldown-scale-design.md
*/
export type LeadStatus = 'delivered' | 'no_match' | 'pending' | 'stuck' | 'error';
export interface LeadRow {
id: number;
received_at: string;
platform: string;
channel: string | null;
source: string | null;
region_code: number | null;
phone_masked: string;
deals_created_count: number;
status: string;
}
export interface LeadsPage {
data: LeadRow[];
total: number;
page: number;
per_page: number;
}
export interface LeadsFilters {
page?: number;
per_page?: number;
date_from?: string;
date_to?: string;
channel?: string;
platform?: string;
status?: string;
tenant_id?: number;
search?: string;
}
export interface LeadDetail {
lead: {
id: number;
platform: string;
phone_masked: string;
received_at: string;
processed_at: string | null;
error: string | null;
region_code: number | null;
region_source: string | null;
phone_operator: string | null;
deals_created_count: number;
status: string;
};
source: {
platform: string;
channel: string | null;
identifier: string | null;
supplier_project_id: number | null;
};
deals: Array<{
id: number;
tenant_id: number;
tenant_name: string;
subdomain: string;
status: string;
project_id: number | null;
received_at: string;
}>;
}
export async function getLeads(filters: LeadsFilters): Promise<LeadsPage> {
const { data } = await apiClient.get<LeadsPage>('/api/admin/leads', { params: filters });
return data;
}
export async function getLead(id: number | string): Promise<LeadDetail> {
const { data } = await apiClient.get<LeadDetail>(`/api/admin/leads/${id}`);
return data;
}
+1
View File
@@ -27,6 +27,7 @@ interface NavItem {
const navItems: NavItem[] = [
{ title: 'Командный центр', icon: 'mdi-view-dashboard-outline', to: '/admin/dashboard' },
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants' },
{ title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
+12
View File
@@ -222,6 +222,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/admin/AdminBillingView.vue'),
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true, devIndex: 23, devLabel: 'Admin Billing' },
},
{
path: '/admin/leads',
name: 'admin-leads',
component: () => import('../views/admin/AdminLeadsView.vue'),
meta: { layout: 'admin', title: 'Лиды', requiresAuth: true, devLabel: 'Admin Leads' },
},
{
path: '/admin/leads/:id',
name: 'admin-lead-detail',
component: () => import('../views/admin/AdminLeadDetailView.vue'),
meta: { layout: 'admin', title: 'Лид', requiresAuth: true, devLabel: 'Admin Lead Detail' },
},
{
path: '/admin/incidents',
name: 'admin-incidents',
@@ -216,6 +216,19 @@ function openTenant(subdomain: string) {
router.push({ name: 'admin-tenant-detail', params: { code: subdomain } });
}
function openLead(id: number) {
router.push({ name: 'admin-lead-detail', params: { id: String(id) } });
}
function leadChannel(c: string | null): string {
return c === 'site' ? 'Сайт' : c === 'call' ? 'Звонок' : c === 'sms' ? 'SMS' : '—';
}
function leadTime(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;
}
onMounted(load);
defineExpose({ period, dateFrom, dateTo, showCustom, selected, summary, finance, health, leads, supply, balances, clients, loading, fetchError, load });
@@ -706,9 +719,39 @@ defineExpose({ period, dateFrom, dateTo, showCustom, selected, summary, finance,
</div>
</v-col>
</v-row>
<div class="d-flex align-center justify-space-between mb-2">
<h4 class="panel__h4 mb-0">Последние лиды</h4>
<v-btn variant="text" size="small" class="text-none" color="primary"
data-testid="open-all-leads" to="/admin/leads">
Открыть все лиды
</v-btn>
</div>
<v-table density="compact">
<thead>
<tr><th>Время</th><th>Канал</th><th>Источник</th><th>Поставщик</th><th>Телефон</th><th>Статус</th></tr>
</thead>
<tbody>
<tr v-for="r in leads?.recent ?? []" :key="r.id" class="clk" @click="openLead(r.id)">
<td class="num">{{ leadTime(r.received_at) }}</td>
<td>{{ leadChannel(r.channel) }}</td>
<td>{{ r.source ?? '—' }}</td>
<td>{{ r.platform }}</td>
<td class="num">{{ r.phone_masked }}</td>
<td>
<v-chip :color="r.delivered ? 'success' : r.processed ? 'warning' : 'info'" size="x-small" variant="tonal">
{{ r.delivered ? 'доставлен' : r.processed ? 'без получателя' : 'в обработке' }}
</v-chip>
</td>
</tr>
<tr v-if="(leads?.recent?.length ?? 0) === 0">
<td colspan="6" class="text-center text-medium-emphasis">Лидов пока нет</td>
</tr>
</tbody>
</v-table>
<p class="text-medium-emphasis text-body-2 mt-2">
«Доставлено» сделки, созданные клиентам сегодня. «Получено» лиды, пришедшие от поставщика
сегодня. «Зависшие» лиды без распределения дольше 4 часов (если их много проблема синхронизации).
Клик по лиду карточка с полной цепочкой: откуда пришёл (поставщик + канал + регион) и кому ушёл.
Полный список с фильтрами и поиском «Открыть все лиды».
</p>
</v-card-text>
</v-card>
@@ -0,0 +1,147 @@
<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>
@@ -0,0 +1,194 @@
<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>
+2
View File
@@ -117,6 +117,8 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
Route::get('/api/admin/dashboard/supply', 'App\Http\Controllers\Api\AdminDashboardController@supply');
Route::get('/api/admin/dashboard/balances', 'App\Http\Controllers\Api\AdminDashboardController@balances');
Route::get('/api/admin/dashboard/clients', 'App\Http\Controllers\Api\AdminDashboardController@clients');
Route::get('/api/admin/leads', 'App\Http\Controllers\Api\AdminLeadsController@index');
Route::get('/api/admin/leads/{id}', 'App\Http\Controllers\Api\AdminLeadsController@show')->whereNumber('id');
// SaaS-admin impersonation flow (Ю-1). Авторизация — через гейт группы (EnsureSaasAdmin).
Route::prefix('/api/admin/impersonation')->group(function () {
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
function seedLeadTenant(): int
{
return DB::table('tenants')->insertGetId([
'subdomain' => 'acme'.uniqid(), 'organization_name' => 'Acme', 'contact_email' => 'a@acme.ru',
'status' => 'active', 'is_trial' => false, 'balance_rub' => 0, 'balance_leads' => 0,
'chargeback_unrecovered_rub' => 0, 'created_at' => now(), 'updated_at' => now(),
]);
}
function seedSupplierProject(string $signal, string $key): int
{
return DB::table('supplier_projects')->insertGetId([
'platform' => 'B1', 'signal_type' => $signal, 'unique_key' => $key,
'current_limit' => 10, 'sync_status' => 'ok', 'created_at' => now(), 'updated_at' => now(),
]);
}
it('GET /api/admin/leads — пагинированный список с фильтром по каналу', function () {
$sp = seedSupplierProject('site', 'okna.ru');
DB::table('supplier_leads')->insert([
['supplier_project_id' => $sp, 'platform' => 'B1', 'raw_payload' => json_encode(['x' => 1]),
'phone' => '+79135397707', 'vid' => 1001, 'received_at' => now()->subHour(),
'processed_at' => now()->subHour(), 'deals_created_count' => 2],
['supplier_project_id' => null, 'platform' => 'B2', 'raw_payload' => json_encode(['x' => 2]),
'phone' => '+79990001122', 'vid' => 1002, 'received_at' => now()->subDays(2),
'processed_at' => null, 'deals_created_count' => 0],
]);
$res = $this->getJson('/api/admin/leads?per_page=10');
$res->assertOk();
$res->assertJsonStructure([
'data' => [['id', 'received_at', 'platform', 'channel', 'source', 'region_code', 'phone_masked', 'deals_created_count', 'status']],
'total', 'page', 'per_page',
]);
expect($res->json('total'))->toBeGreaterThanOrEqual(2);
// фильтр по каналу site → только лид с supplier_project site
$res2 = $this->getJson('/api/admin/leads?channel=site');
expect(collect($res2->json('data'))->pluck('channel')->unique()->all())->toBe(['site']);
});
it('телефон в списке маскируется', function () {
$sp = seedSupplierProject('call', '+74950000000');
DB::table('supplier_leads')->insert([
'supplier_project_id' => $sp, 'platform' => 'B1', 'raw_payload' => json_encode([]),
'phone' => '+79135397707', 'vid' => 2001, 'received_at' => now(), 'deals_created_count' => 0,
]);
$res = $this->getJson('/api/admin/leads?channel=call');
$masked = collect($res->json('data'))->firstWhere('id', '!=', null)['phone_masked'] ?? '';
expect($masked)->not->toContain('9135397'); // середина скрыта
expect($masked)->toContain('**');
});
it('GET /api/admin/leads/{id} — карточка с цепочкой: источник + сделки клиентов', function () {
$sp = seedSupplierProject('site', 'okna.ru');
$leadId = DB::table('supplier_leads')->insertGetId([
'supplier_project_id' => $sp, 'platform' => 'B1', 'raw_payload' => json_encode(['tag' => 77]),
'phone' => '+79135397707', 'vid' => 5005, 'received_at' => now()->subHour(),
'processed_at' => now()->subHour(), 'deals_created_count' => 1, 'resolved_subject_code' => 77,
]);
$tenant = seedLeadTenant();
$project = DB::table('projects')->insertGetId([
'tenant_id' => $tenant, 'name' => 'Проект', 'is_active' => true,
'created_at' => now(), 'updated_at' => now(),
]);
DB::table('deals')->insert([
'tenant_id' => $tenant, 'project_id' => $project, 'source_crm_id' => 5005, 'phone' => '+79135397707',
'status' => 'new', 'is_test' => false, 'received_at' => now()->subHour(), 'created_at' => now(), 'updated_at' => now(),
]);
$res = $this->getJson("/api/admin/leads/{$leadId}");
$res->assertOk();
$res->assertJsonStructure([
'lead' => ['id', 'platform', 'phone_masked', 'received_at', 'region_code', 'status'],
'source' => ['platform', 'channel', 'identifier'],
'deals' => [['tenant_id', 'tenant_name', 'status']],
]);
expect($res->json('source.identifier'))->toBe('okna.ru');
expect($res->json('deals.0.tenant_name'))->toBe('Acme');
});
@@ -45,6 +45,9 @@ vi.mock('../../resources/js/api/adminDashboard', () => ({
getDashboardLeads: vi.fn().mockResolvedValue({
light: 'green',
kpi: { delivered_today: 71, received_today: 80, stuck: 0, unrouted: 0 },
recent: [
{ id: 501, received_at: '2026-06-28 07:55', platform: 'B1', channel: 'site', source: 'okna.ru', phone_masked: '79***07', delivered: true, processed: true },
],
}),
getDashboardSupply: vi.fn().mockResolvedValue({
snapshot_date: '2026-06-28',
@@ -160,6 +163,17 @@ describe('AdminDashboardView.vue', () => {
expect(wrapper.text()).toContain('По группам');
});
it('drill Лиды показывает последние лиды и ссылку «Открыть все лиды»', async () => {
const { wrapper } = await factory();
await wrapper.find('[data-testid="tile-leads"]').trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="drill-leads"]').exists()).toBe(true);
expect(wrapper.text()).toContain('okna.ru'); // recent lead source
const link = wrapper.find('[data-testid="open-all-leads"]');
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toContain('/admin/leads');
});
it('Финансы и Здоровье показывают живые числа из API', async () => {
const { wrapper } = await factory();
const text = wrapper.text();
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import AdminLeadsView from '../../resources/js/views/admin/AdminLeadsView.vue';
import AdminLeadDetailView from '../../resources/js/views/admin/AdminLeadDetailView.vue';
vi.mock('../../resources/js/api/adminLeads', () => ({
getLeads: vi.fn().mockResolvedValue({
data: [
{ id: 501, received_at: '2026-06-28 07:55', platform: 'B1', channel: 'site', source: 'okna.ru', region_code: 77, phone_masked: '79***07', deals_created_count: 2, status: 'delivered' },
],
total: 1, page: 1, per_page: 25,
}),
getLead: vi.fn().mockResolvedValue({
lead: { id: 501, platform: 'B1', phone_masked: '79***07', received_at: '2026-06-28 07:55', processed_at: '2026-06-28 07:56', error: null, region_code: 77, region_source: 'dadata', phone_operator: 'МТС', deals_created_count: 1, status: 'delivered' },
source: { platform: 'B1', channel: 'site', identifier: 'okna.ru', supplier_project_id: 9 },
deals: [{ id: 1, tenant_id: 2, tenant_name: 'Компания 1', subdomain: 'c1', status: 'new', project_id: 5, received_at: '2026-06-28 07:56' }],
}),
}));
beforeEach(() => vi.clearAllMocks());
function routerWith(path: string) {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/admin/leads', name: 'admin-leads', component: AdminLeadsView },
{ path: '/admin/leads/:id', name: 'admin-lead-detail', component: AdminLeadDetailView },
{ path: '/admin/tenants/:code', name: 'admin-tenant-detail', component: { template: '<div/>' } },
{ path: '/admin/supplier-projects', name: 'admin-supplier-projects', component: { template: '<div/>' } },
{ path: '/admin/dashboard', name: 'admin-dashboard', component: { template: '<div/>' } },
],
});
return router.push(path).then(() => router);
}
describe('AdminLeadsView', () => {
it('грузит список и показывает строку лида', async () => {
const router = await routerWith('/admin/leads');
const wrapper = mount(AdminLeadsView, { global: { plugins: [createVuetify(), router] } });
await flushPromises();
expect(wrapper.text()).toContain('okna.ru');
expect(wrapper.text()).toContain('доставлен');
const api = await import('../../resources/js/api/adminLeads');
expect(api.getLeads).toHaveBeenCalled();
});
it('фильтр по каналу шлёт channel и сбрасывает на 1 страницу', async () => {
const router = await routerWith('/admin/leads');
const wrapper = mount(AdminLeadsView, { global: { plugins: [createVuetify(), router] } });
await flushPromises();
const api = await import('../../resources/js/api/adminLeads');
wrapper.vm.filters.channel = 'call';
await wrapper.find('[data-testid="apply-filters"]').trigger('click');
await flushPromises();
expect(vi.mocked(api.getLeads).mock.calls.at(-1)?.[0]).toMatchObject({ channel: 'call', page: 1 });
});
});
describe('AdminLeadDetailView', () => {
it('показывает цепочку: откуда (источник) и кому (сделки)', async () => {
const router = await routerWith('/admin/leads/501');
const wrapper = mount(AdminLeadDetailView, { global: { plugins: [createVuetify(), router] } });
await flushPromises();
expect(wrapper.find('[data-testid="lead-source"]').exists()).toBe(true);
expect(wrapper.text()).toContain('okna.ru'); // источник
expect(wrapper.find('[data-testid="lead-deals"]').exists()).toBe(true);
expect(wrapper.text()).toContain('Компания 1'); // кому ушёл
});
});
+5 -6
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-06-28T05:42:31.988Z
Last updated: 2026-06-28T06:54:27.588Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -33,7 +33,7 @@ Last updated: 2026-06-28T05:42:31.988Z
| enforce-coverage-verify.mjs | `enforce-coverage-verify.mjs` | 🔴 |
| enforce-todowrite-skill-verifier.mjs | `enforce-todowrite-skill-verifier.mjs` | 🔴 |
Недавние escape владельца: 0 · Недавние блоки: 5
Недавние escape владельца: 0 · Недавние блоки: 4
**Недавние блоки (детали):**
@@ -43,7 +43,6 @@ Last updated: 2026-06-28T05:42:31.988Z
| 2026-06-27T10:01:08.010Z | bash:git restore --staged docs/observer/STATUS.md 2>/dev/null; git diff --staged --name-only | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:git restore --staged d |
| 2026-06-27T09:25:54.127Z | bash:node -e "for (const d of ['протокол-наставника','проблема-закрытия-вопросов-протокола','содержит']) { try { const p | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "for (const d |
| 2026-06-27T07:03:56.852Z | bash:node -e "1" 2>/dev/null; for f in docs/secretary/*/protocol.md; do printf '%6s %s\n' "$(wc -l < "$f")" "$f"; done | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "1" 2>/dev/nul |
| 2026-06-27T05:45:19.915Z | bash:rm ~/.claude/runtime/secretary-mode-*.json | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:rm ~/.claude/runtime/s |
## Метрики (информационные, не алерты)
@@ -127,9 +126,9 @@ Episodes since last run: 542 / threshold: 10
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3440 | MsMpEng | 17.25ч | NaNч |
| 21928 | Code | 7.53ч | 0.0ч |
| 1212 | svchost | 4.42ч | 0.0ч |
| 3440 | MsMpEng | 17.42ч | 0.0ч |
| 21928 | Code | 7.68ч | NaNч |
| 1212 | svchost | 4.45ч | 451353.9ч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.