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:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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'); // кому ушёл
|
||||
});
|
||||
});
|
||||
@@ -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-сессий.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user