Add search, CSV export, kanban, charts, toasts, keyboard shortcuts

Backend:
- /leads now accepts ?q= and matches contact_name, company_name, email,
  contact_phone (case-insensitive substring)
- New GET /leads/export streams CSV (BOM + UTF-8) with same filters
- /stats/managers now returns funnel (per-stage count + amount),
  forecast (Σ amount × stage weight), and 14-day new-leads timeline

Frontend:
- LeadsTable: search input with 250ms debounce and / shortcut, view
  toggle (Таблица / Канбан, K shortcut), CSV button, skeleton loader,
  mobile card layout, toast notifications on delete
- KanbanBoard: 5-column board grouping leads by deal_stage with
  per-column count and pipeline total
- StatsView: forecast KPI card, funnel widget, 14-day activity bar
  chart, manager-share progress bars
- Toast system: useToast composable + ToastHost portal in App
- Skeleton: simple animated placeholder component
- Keyboard shortcuts (LeadsTable): n=new, /=focus search, k=toggle view
- API/store: q query param plumbing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ask 2026-05-19 16:47:02 +05:00
parent 12cf6ba713
commit 0950c9f755
12 changed files with 723 additions and 84 deletions

View File

@ -169,10 +169,11 @@ export async function leadRoutes(app: FastifyInstance) {
limit?: string; limit?: string;
offset?: string; offset?: string;
created_by?: string; created_by?: string;
q?: string;
}; };
}>('/leads', async (req) => { }>('/leads', async (req) => {
const user = await requireAuth(app, req); const user = await requireAuth(app, req);
const { status, lead_type, deal_stage, sort, limit, offset, created_by } = req.query; const { status, lead_type, deal_stage, sort, limit, offset, created_by, q } = req.query;
const where: string[] = []; const where: string[] = [];
const params: unknown[] = []; const params: unknown[] = [];
@ -184,6 +185,14 @@ export async function leadRoutes(app: FastifyInstance) {
where.push(`created_by = $${params.length}`); where.push(`created_by = $${params.length}`);
} }
if (q && q.trim()) {
params.push(`%${q.trim().toLowerCase()}%`);
const i = params.length;
where.push(
`(LOWER(contact_name) LIKE $${i} OR LOWER(company_name) LIKE $${i} OR LOWER(email) LIKE $${i} OR LOWER(contact_phone) LIKE $${i})`,
);
}
if (status && status !== 'all') { if (status && status !== 'all') {
if (!['cold', 'warm', 'hot'].includes(status)) if (!['cold', 'warm', 'hot'].includes(status))
throw AppError.validation('invalid status'); throw AppError.validation('invalid status');
@ -251,6 +260,103 @@ export async function leadRoutes(app: FastifyInstance) {
return { data, total: Number(countQ.rows[0]?.count ?? 0) }; return { data, total: Number(countQ.rows[0]?.count ?? 0) };
}); });
// EXPORT CSV
app.get<{
Querystring: {
status?: string;
lead_type?: string;
deal_stage?: string;
created_by?: string;
q?: string;
};
}>('/leads/export', async (req, reply) => {
const user = await requireAuth(app, req);
const { status, lead_type, deal_stage, created_by, q } = req.query;
const where: string[] = [];
const params: unknown[] = [];
if (user.role !== 'rop') {
params.push(user.sub);
where.push(`created_by = $${params.length}`);
} else if (created_by) {
params.push(created_by);
where.push(`created_by = $${params.length}`);
}
if (q && q.trim()) {
params.push(`%${q.trim().toLowerCase()}%`);
const i = params.length;
where.push(
`(LOWER(contact_name) LIKE $${i} OR LOWER(company_name) LIKE $${i} OR LOWER(email) LIKE $${i} OR LOWER(contact_phone) LIKE $${i})`,
);
}
if (status && status !== 'all' && ['cold', 'warm', 'hot'].includes(status)) {
params.push(status);
where.push(`status = $${params.length}`);
}
if (lead_type && lead_type !== 'all' && ['inbound', 'outbound'].includes(lead_type)) {
params.push(lead_type);
where.push(`lead_type = $${params.length}`);
}
if (deal_stage && deal_stage !== 'all' && (DEAL_STAGES as string[]).includes(deal_stage)) {
params.push(deal_stage);
where.push(`deal_stage = $${params.length}`);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const { rows } = await query<LeadRow>(
`SELECT * FROM leads ${whereSql} ORDER BY created_at DESC`,
params,
);
const headers = [
'id',
'contact_name',
'contact_phone',
'email',
'position',
'company_name',
'website',
'industry',
'city',
'lead_type',
'score',
'status',
'deal_stage',
'expected_amount',
'next_action_date',
'created_by',
'created_at',
];
function escape(v: unknown): string {
if (v === null || v === undefined) return '';
const s = String(v);
if (s.includes('"') || s.includes(',') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
const lines = [headers.join(',')];
for (const r of rows) {
lines.push(
headers
.map((h) => {
if (h === 'created_at') return escape(r.created_at);
if (h === 'next_action_date') return escape(formatDateOnly(r.next_action_date));
return escape((r as unknown as Record<string, unknown>)[h]);
})
.join(','),
);
}
const filename = `leads-${new Date().toISOString().slice(0, 10)}.csv`;
reply
.header('Content-Type', 'text/csv; charset=utf-8')
.header('Content-Disposition', `attachment; filename="${filename}"`)
.send('' + lines.join('\n'));
});
// CREATE // CREATE
app.post<{ Body: LeadBody }>('/leads', async (req) => { app.post<{ Body: LeadBody }>('/leads', async (req) => {
const user = req.user as { sub: string }; const user = req.user as { sub: string };

View File

@ -19,6 +19,14 @@ interface ManagerStatRow {
new_30d: string; new_30d: string;
} }
const STAGE_WEIGHTS: Record<string, number> = {
new: 0.1,
contacted: 0.3,
proposal: 0.6,
won: 1,
lost: 0,
};
export async function statsRoutes(app: FastifyInstance) { export async function statsRoutes(app: FastifyInstance) {
app.get('/stats/managers', async (req) => { app.get('/stats/managers', async (req) => {
const user = await requireAuth(app, req); const user = await requireAuth(app, req);
@ -106,6 +114,43 @@ export async function statsRoutes(app: FastifyInstance) {
}, },
); );
return { managers, totals }; const funnelRes = await query<{ deal_stage: string; count: string; amount: string }>(
`SELECT deal_stage, COUNT(*)::text AS count, COALESCE(SUM(expected_amount),0)::text AS amount
FROM leads
GROUP BY deal_stage`,
);
const funnelMap = new Map<string, { count: number; amount: number }>();
for (const r of funnelRes.rows)
funnelMap.set(r.deal_stage, { count: Number(r.count), amount: Number(r.amount) });
const funnel = ['new', 'contacted', 'proposal', 'won', 'lost'].map((stage) => ({
stage,
count: funnelMap.get(stage)?.count ?? 0,
amount: funnelMap.get(stage)?.amount ?? 0,
}));
let forecast = 0;
for (const { stage, amount } of funnel) {
forecast += amount * (STAGE_WEIGHTS[stage] ?? 0);
}
const timelineRes = await query<{ day: Date; count: string }>(
`WITH days AS (
SELECT generate_series(CURRENT_DATE - INTERVAL '13 days', CURRENT_DATE, INTERVAL '1 day')::date AS day
)
SELECT days.day, COUNT(leads.id)::text AS count
FROM days
LEFT JOIN leads ON DATE(leads.created_at) = days.day
GROUP BY days.day
ORDER BY days.day`,
);
const timeline = timelineRes.rows.map((r) => {
const d = r.day instanceof Date ? r.day : new Date(String(r.day));
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return { date: `${y}-${m}-${day}`, count: Number(r.count) };
});
return { managers, totals, funnel, forecast: Math.round(forecast), timeline };
}); });
} }

View File

@ -2,6 +2,7 @@
import { RouterLink, RouterView, useRouter } from 'vue-router'; import { RouterLink, RouterView, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import BellMenu from '@/components/BellMenu.vue'; import BellMenu from '@/components/BellMenu.vue';
import ToastHost from '@/components/ToastHost.vue';
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
@ -78,5 +79,6 @@ function userInitials(name: string | undefined): string {
<main class="max-w-7xl mx-auto px-6 py-8"> <main class="max-w-7xl mx-auto px-6 py-8">
<RouterView /> <RouterView />
</main> </main>
<ToastHost />
</div> </div>
</template> </template>

View File

@ -120,6 +120,7 @@ export interface LeadListQuery {
limit?: number; limit?: number;
offset?: number; offset?: number;
created_by?: string; created_by?: string;
q?: string;
} }
export interface CreateLeadBody { export interface CreateLeadBody {

View File

@ -33,8 +33,23 @@ export interface ManagerTotals {
new_30d: number; new_30d: number;
} }
export function getManagerStats() { export interface FunnelEntry {
return apiFetch<{ managers: ManagerStat[]; totals: ManagerTotals }>( stage: string;
'/stats/managers', count: number;
); amount: number;
}
export interface TimelineEntry {
date: string;
count: number;
}
export function getManagerStats() {
return apiFetch<{
managers: ManagerStat[];
totals: ManagerTotals;
funnel: FunnelEntry[];
forecast: number;
timeline: TimelineEntry[];
}>('/stats/managers');
} }

View File

@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useLeadsStore } from '@/stores/leads';
import ScoreBadge from '@/components/ScoreBadge.vue';
import { DEAL_STAGES, DEAL_STAGE_LABELS, type DealStage } from '@/api/leads';
const store = useLeadsStore();
const router = useRouter();
const columns = computed(() => {
const map: Record<DealStage, typeof store.items> = {
new: [],
contacted: [],
proposal: [],
won: [],
lost: [],
};
for (const lead of store.items) map[lead.deal_stage].push(lead);
return DEAL_STAGES.map((s) => ({
stage: s,
label: DEAL_STAGE_LABELS[s],
items: map[s],
}));
});
const stageHead: Record<DealStage, string> = {
new: 'bg-gray-50 text-gray-700',
contacted: 'bg-sky-50 text-sky-800',
proposal: 'bg-amber-50 text-amber-800',
won: 'bg-emerald-50 text-emerald-800',
lost: 'bg-red-50 text-red-800',
};
function money(v: number | null) {
if (v === null) return '';
if (v >= 1_000_000) return (v / 1_000_000).toFixed(1).replace('.0', '') + 'M ₸';
if (v >= 1_000) return Math.round(v / 1_000) + 'K ₸';
return v.toLocaleString('ru-RU') + ' ₸';
}
function stageTotal(items: typeof store.items): number {
return items.reduce((acc, l) => acc + (l.expected_amount ?? 0), 0);
}
</script>
<template>
<div class="grid grid-cols-5 gap-3 overflow-x-auto pb-2">
<div
v-for="col in columns"
:key="col.stage"
class="card min-w-[220px] flex flex-col"
>
<div
class="px-3 py-2 border-b border-gray-100 flex items-center justify-between rounded-t-2xl"
:class="stageHead[col.stage]"
>
<div class="text-xs font-semibold uppercase tracking-wider">
{{ col.label }}
</div>
<div class="text-xs tabular-nums opacity-70">
{{ col.items.length }}<span v-if="stageTotal(col.items) > 0"> · {{ money(stageTotal(col.items)) }}</span>
</div>
</div>
<div class="p-2 space-y-2 min-h-[100px]">
<div
v-if="col.items.length === 0"
class="text-xs text-gray-400 italic text-center py-6"
>
—
</div>
<button
v-for="lead in col.items"
:key="lead.id"
type="button"
class="w-full text-left bg-white border border-gray-100 rounded-lg p-2.5 hover:border-gray-300 transition shadow-sm"
@click="router.push({ name: 'lead-card', params: { id: lead.id } })"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">{{ lead.contact_name }}</div>
<div class="text-xs text-gray-500 truncate">{{ lead.company_name }}</div>
</div>
<ScoreBadge :score="lead.score" :status="lead.status" />
</div>
<div
v-if="lead.expected_amount"
class="mt-2 text-xs text-gray-600 tabular-nums"
>
{{ money(lead.expected_amount) }}
</div>
<div class="mt-1 text-[10px] uppercase tracking-wider text-gray-400">
{{ lead.created_by }}
</div>
</button>
</div>
</div>
</div>
</template>

View File

@ -1,9 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useLeadsStore } from '@/stores/leads'; import { useLeadsStore } from '@/stores/leads';
import { useToast } from '@/composables/useToast';
import ScoreBadge from '@/components/ScoreBadge.vue'; import ScoreBadge from '@/components/ScoreBadge.vue';
import FilterBar from '@/components/FilterBar.vue'; import FilterBar from '@/components/FilterBar.vue';
import Skeleton from '@/components/Skeleton.vue';
import KanbanBoard from '@/components/KanbanBoard.vue';
import { import {
DEAL_STAGE_LABELS, DEAL_STAGE_LABELS,
LEAD_SORT_LABELS, LEAD_SORT_LABELS,
@ -15,6 +19,35 @@ import {
const store = useLeadsStore(); const store = useLeadsStore();
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
const toast = useToast();
const view = ref<'table' | 'kanban'>('table');
const searchInput = ref(store.q);
const searchRef = ref<HTMLInputElement | null>(null);
let searchTimer: number | null = null;
watch(searchInput, (v) => {
if (searchTimer !== null) window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => store.setQuery(v), 250);
});
function onKeydown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.key === '/') {
e.preventDefault();
searchRef.value?.focus();
} else if (e.key === 'n') {
e.preventDefault();
router.push({ name: 'new-lead' });
} else if (e.key === 'k') {
e.preventDefault();
view.value = view.value === 'table' ? 'kanban' : 'table';
}
}
onMounted(() => document.addEventListener('keydown', onKeydown));
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown));
const sortOptions: LeadSort[] = [ const sortOptions: LeadSort[] = [
'score_desc', 'score_desc',
@ -54,33 +87,105 @@ async function removeLead(id: string, name: string) {
try { try {
await deleteLead(id); await deleteLead(id);
await store.load(); await store.load();
toast.success(`Лид «${name}» удалён`);
} catch (e) { } catch (e) {
alert((e as Error).message); toast.error((e as Error).message);
} }
} }
function exportCsv() {
const params = new URLSearchParams();
if (store.q.trim()) params.set('q', store.q.trim());
if (store.filters.status !== 'all') params.set('status', store.filters.status);
if (store.filters.lead_type !== 'all') params.set('lead_type', store.filters.lead_type);
if (store.filters.deal_stage !== 'all') params.set('deal_stage', store.filters.deal_stage);
if (store.createdBy) params.set('created_by', store.createdBy);
const token = localStorage.getItem('token');
fetch(`/api/leads/export?${params.toString()}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => {
if (!r.ok) throw new Error('Ошибка экспорта');
return r.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `leads-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success('CSV скачан');
})
.catch((e) => toast.error(e.message));
}
</script> </script>
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<div <div class="card px-4 py-3 space-y-3">
class="card px-4 py-3 flex flex-wrap items-center justify-between gap-3" <div class="flex flex-wrap items-center gap-3">
> <div class="relative flex-1 min-w-[200px]">
<FilterBar /> <input
<div class="flex items-center gap-2 text-sm"> ref="searchRef"
<label class="text-xs text-gray-500">Сортировка</label> v-model="searchInput"
<select type="text"
:value="store.sort" placeholder="Поиск: имя, компания, телефон, email… (/)"
@change="store.setSort(($event.target as HTMLSelectElement).value as LeadSort)" class="input-base pl-8 !py-1.5 text-sm"
class="input-base !py-1 !px-2 w-auto text-xs" />
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"/></svg>
</span>
</div>
<div class="flex items-center gap-2 text-sm">
<label class="text-xs text-gray-500">Сортировка</label>
<select
:value="store.sort"
@change="store.setSort(($event.target as HTMLSelectElement).value as LeadSort)"
class="input-base !py-1 !px-2 w-auto text-xs"
>
<option v-for="s in sortOptions" :key="s" :value="s">
{{ LEAD_SORT_LABELS[s] }}
</option>
</select>
</div>
<div class="flex border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
class="px-2.5 py-1.5 text-xs transition"
:class="view === 'table' ? 'bg-gray-900 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
title="Таблица (K)"
@click="view = 'table'"
>
Таблица
</button>
<button
type="button"
class="px-2.5 py-1.5 text-xs transition border-l border-gray-200"
:class="view === 'kanban' ? 'bg-gray-900 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
title="Канбан (K)"
@click="view = 'kanban'"
>
Канбан
</button>
</div>
<button
type="button"
class="btn-secondary !py-1.5 text-xs"
title="Экспорт в CSV"
@click="exportCsv"
> >
<option v-for="s in sortOptions" :key="s" :value="s"> CSV
{{ LEAD_SORT_LABELS[s] }} </button>
</option>
</select>
</div> </div>
<FilterBar />
</div> </div>
<div v-if="store.loading" class="card p-12 text-center text-gray-400">Загрузка…</div> <div v-if="store.loading" class="card p-4">
<Skeleton :rows="6" height="44px" />
</div>
<div <div
v-else-if="store.error" v-else-if="store.error"
class="card p-6 text-sm text-red-600 bg-red-50/50 border-red-100" class="card p-6 text-sm text-red-600 bg-red-50/50 border-red-100"
@ -95,75 +200,110 @@ async function removeLead(id: string, name: string) {
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"/></svg>
</div> </div>
<div class="text-sm font-medium text-gray-900">Лидов не найдено</div> <div class="text-sm font-medium text-gray-900">Лидов не найдено</div>
<div class="text-xs text-gray-500 mt-1">Попробуйте сбросить фильтры или добавить нового.</div> <div class="text-xs text-gray-500 mt-1">Попробуйте сбросить фильтры или поиск.</div>
</div> </div>
<div v-else class="card overflow-hidden">
<table class="table-base"> <template v-else>
<thead> <KanbanBoard v-if="view === 'kanban'" />
<tr>
<th>Score</th> <template v-else>
<th>Контакт</th> <div class="card overflow-hidden hidden md:block">
<th>Компания</th> <table class="table-base">
<th>Этап</th> <thead>
<th class="text-right">Сумма</th> <tr>
<th>Касание</th> <th>Score</th>
<th>Тип</th> <th>Контакт</th>
<th>Автор</th> <th>Компания</th>
<th></th> <th>Этап</th>
</tr> <th class="text-right">Сумма</th>
</thead> <th>Касание</th>
<tbody> <th>Тип</th>
<tr <th>Автор</th>
<th></th>
</tr>
</thead>
<tbody>
<tr
v-for="lead in store.items"
:key="lead.id"
class="cursor-pointer"
@click="router.push({ name: 'lead-card', params: { id: lead.id } })"
>
<td class="px-4 py-3">
<ScoreBadge :score="lead.score" :status="lead.status" />
</td>
<td class="px-4 py-3">
<div class="font-medium text-gray-900">{{ lead.contact_name }}</div>
<div v-if="lead.position" class="text-xs text-gray-500">{{ lead.position }}</div>
</td>
<td class="px-4 py-3">
<div class="text-gray-900">{{ lead.company_name }}</div>
<div v-if="lead.city" class="text-xs text-gray-500">{{ lead.city }}</div>
</td>
<td class="px-4 py-3">
<span class="badge" :class="stageClass[lead.deal_stage]">
{{ DEAL_STAGE_LABELS[lead.deal_stage] }}
</span>
</td>
<td class="px-4 py-3 text-right tabular-nums text-gray-700">
{{ formatMoney(lead.expected_amount) }}
</td>
<td class="px-4 py-3 text-gray-600 tabular-nums">
{{ formatDate(lead.next_action_date) }}
</td>
<td class="px-4 py-3 text-gray-500 text-xs uppercase tracking-wider">
{{ lead.lead_type === 'inbound' ? 'In' : 'Out' }}
</td>
<td class="px-4 py-3 text-gray-600 text-xs">{{ lead.created_by }}</td>
<td class="px-4 py-3 text-right">
<button
v-if="auth.isRop"
type="button"
class="text-xs text-red-600 hover:text-red-800 transition"
title="Удалить"
@click.stop="removeLead(lead.id, lead.contact_name)"
>
Удалить
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="md:hidden space-y-2">
<button
v-for="lead in store.items" v-for="lead in store.items"
:key="lead.id" :key="lead.id"
class="cursor-pointer" type="button"
class="card p-3 w-full text-left flex flex-col gap-2"
@click="router.push({ name: 'lead-card', params: { id: lead.id } })" @click="router.push({ name: 'lead-card', params: { id: lead.id } })"
> >
<td class="px-4 py-3"> <div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-medium truncate">{{ lead.contact_name }}</div>
<div class="text-xs text-gray-500 truncate">{{ lead.company_name }}</div>
</div>
<ScoreBadge :score="lead.score" :status="lead.status" /> <ScoreBadge :score="lead.score" :status="lead.status" />
</td> </div>
<td class="px-4 py-3"> <div class="flex items-center justify-between text-xs">
<div class="font-medium text-gray-900">{{ lead.contact_name }}</div>
<div v-if="lead.position" class="text-xs text-gray-500">{{ lead.position }}</div>
</td>
<td class="px-4 py-3">
<div class="text-gray-900">{{ lead.company_name }}</div>
<div v-if="lead.city" class="text-xs text-gray-500">{{ lead.city }}</div>
</td>
<td class="px-4 py-3">
<span class="badge" :class="stageClass[lead.deal_stage]"> <span class="badge" :class="stageClass[lead.deal_stage]">
{{ DEAL_STAGE_LABELS[lead.deal_stage] }} {{ DEAL_STAGE_LABELS[lead.deal_stage] }}
</span> </span>
</td> <span class="text-gray-600 tabular-nums">{{ formatMoney(lead.expected_amount) }}</span>
<td class="px-4 py-3 text-right tabular-nums text-gray-700"> </div>
{{ formatMoney(lead.expected_amount) }} <div class="text-[10px] uppercase tracking-wider text-gray-400 flex justify-between">
</td> <span>{{ lead.created_by }}</span>
<td class="px-4 py-3 text-gray-600 tabular-nums"> <span>{{ formatDate(lead.next_action_date) }}</span>
{{ formatDate(lead.next_action_date) }} </div>
</td> </button>
<td class="px-4 py-3 text-gray-500 text-xs uppercase tracking-wider"> </div>
{{ lead.lead_type === 'inbound' ? 'In' : 'Out' }} </template>
</td> </template>
<td class="px-4 py-3 text-gray-600 text-xs">{{ lead.created_by }}</td>
<td class="px-4 py-3 text-right">
<button
v-if="auth.isRop"
type="button"
class="text-xs text-red-600 hover:text-red-800 transition opacity-0 group-hover:opacity-100"
title="Удалить"
@click.stop="removeLead(lead.id, lead.contact_name)"
>
Удалить
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div <div
v-if="store.items.length > 0" v-if="store.items.length > 0 && view === 'table'"
class="flex items-center justify-between text-sm text-gray-600 px-1" class="flex items-center justify-between text-sm text-gray-600 px-1 flex-wrap gap-2"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<label class="text-xs text-gray-500">На странице</label> <label class="text-xs text-gray-500">На странице</label>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
defineProps<{ rows?: number; height?: string }>();
</script>
<template>
<div class="space-y-2">
<div
v-for="i in (rows ?? 5)"
:key="i"
class="animate-pulse rounded-md bg-gray-100"
:style="{ height: height ?? '36px' }"
></div>
</div>
</template>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import { useToast } from '@/composables/useToast';
const toast = useToast();
const kindClass: Record<string, string> = {
success: 'bg-emerald-50 border-emerald-200 text-emerald-900',
error: 'bg-red-50 border-red-200 text-red-900',
info: 'bg-gray-50 border-gray-200 text-gray-900',
};
const kindIcon: Record<string, string> = {
success: '✓',
error: '✕',
info: 'i',
};
</script>
<template>
<div
class="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none"
>
<transition-group name="toast">
<div
v-for="t in toast.items"
:key="t.id"
class="pointer-events-auto flex items-start gap-2 border rounded-lg px-3 py-2 shadow-sm min-w-[260px] max-w-sm text-sm"
:class="kindClass[t.kind]"
>
<span class="font-bold mt-0.5">{{ kindIcon[t.kind] }}</span>
<span class="flex-1">{{ t.text }}</span>
<button
type="button"
class="text-gray-400 hover:text-gray-700 transition"
@click="toast.dismiss(t.id)"
>
×
</button>
</div>
</transition-group>
</div>
</template>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.2s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(20px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(20px);
}
</style>

View File

@ -0,0 +1,34 @@
import { reactive } from 'vue';
export type ToastKind = 'success' | 'error' | 'info';
export interface Toast {
id: number;
kind: ToastKind;
text: string;
}
const state = reactive<{ items: Toast[] }>({ items: [] });
let nextId = 1;
export function useToast() {
function push(kind: ToastKind, text: string, ttl = 3500) {
const id = nextId++;
state.items.push({ id, kind, text });
setTimeout(() => {
const idx = state.items.findIndex((t) => t.id === id);
if (idx >= 0) state.items.splice(idx, 1);
}, ttl);
}
return {
items: state.items,
success: (text: string) => push('success', text),
error: (text: string) => push('error', text),
info: (text: string) => push('info', text),
dismiss: (id: number) => {
const idx = state.items.findIndex((t) => t.id === id);
if (idx >= 0) state.items.splice(idx, 1);
},
};
}

View File

@ -17,6 +17,7 @@ interface State {
pageSize: number; pageSize: number;
sort: LeadSort; sort: LeadSort;
createdBy: string | null; createdBy: string | null;
q: string;
filters: { filters: {
status: LeadStatus | 'all'; status: LeadStatus | 'all';
lead_type: LeadType | 'all'; lead_type: LeadType | 'all';
@ -33,6 +34,7 @@ const INITIAL_STATE = (): State => ({
pageSize: 20, pageSize: 20,
sort: 'score_desc', sort: 'score_desc',
createdBy: null, createdBy: null,
q: '',
filters: { status: 'all', lead_type: 'all', deal_stage: 'all' }, filters: { status: 'all', lead_type: 'all', deal_stage: 'all' },
}); });
@ -59,6 +61,7 @@ export const useLeadsStore = defineStore('leads', {
limit: this.pageSize, limit: this.pageSize,
offset: (this.page - 1) * this.pageSize, offset: (this.page - 1) * this.pageSize,
created_by: this.createdBy ?? undefined, created_by: this.createdBy ?? undefined,
q: this.q.trim() || undefined,
}); });
this.items = res.data; this.items = res.data;
this.total = res.total; this.total = res.total;
@ -83,6 +86,11 @@ export const useLeadsStore = defineStore('leads', {
this.page = 1; this.page = 1;
return this.load(); return this.load();
}, },
setQuery(q: string) {
this.q = q;
this.page = 1;
return this.load();
},
setSort(sort: LeadSort) { setSort(sort: LeadSort) {
this.sort = sort; this.sort = sort;
this.page = 1; this.page = 1;

View File

@ -1,7 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { getManagerStats, type ManagerStat, type ManagerTotals } from '@/api/stats'; import {
getManagerStats,
type FunnelEntry,
type ManagerStat,
type ManagerTotals,
type TimelineEntry,
} from '@/api/stats';
import Skeleton from '@/components/Skeleton.vue';
const router = useRouter(); const router = useRouter();
@ -11,6 +18,9 @@ function openManager(name: string) {
const managers = ref<ManagerStat[]>([]); const managers = ref<ManagerStat[]>([]);
const totals = ref<ManagerTotals | null>(null); const totals = ref<ManagerTotals | null>(null);
const funnel = ref<FunnelEntry[]>([]);
const forecast = ref(0);
const timeline = ref<TimelineEntry[]>([]);
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
@ -21,6 +31,9 @@ async function load() {
const res = await getManagerStats(); const res = await getManagerStats();
managers.value = res.managers; managers.value = res.managers;
totals.value = res.totals; totals.value = res.totals;
funnel.value = res.funnel;
forecast.value = res.forecast;
timeline.value = res.timeline;
} catch (e) { } catch (e) {
error.value = (e as Error).message; error.value = (e as Error).message;
} finally { } finally {
@ -39,6 +52,33 @@ function money(v: number) {
function score(v: number) { function score(v: number) {
return v.toFixed(1); return v.toFixed(1);
} }
const stageLabels: Record<string, string> = {
new: 'Новые',
contacted: 'Связались',
proposal: 'КП',
won: 'Выиграны',
lost: 'Проиграны',
};
const stageBg: Record<string, string> = {
new: 'bg-gray-200',
contacted: 'bg-sky-300',
proposal: 'bg-amber-300',
won: 'bg-emerald-400',
lost: 'bg-red-300',
};
const funnelMax = computed(() =>
Math.max(1, ...funnel.value.filter((f) => f.stage !== 'lost').map((f) => f.count)),
);
const timelineMax = computed(() => Math.max(1, ...timeline.value.map((t) => t.count)));
const timelineSum = computed(() => timeline.value.reduce((s, t) => s + t.count, 0));
const managerBarMax = computed(() =>
Math.max(1, ...managers.value.map((m) => m.total)),
);
</script> </script>
<template> <template>
@ -58,10 +98,12 @@ function score(v: number) {
</button> </button>
</div> </div>
<div v-if="loading" class="card p-12 text-center text-gray-400">Загрузка…</div> <div v-if="loading" class="card p-6">
<Skeleton :rows="8" height="48px" />
</div>
<div v-else-if="error" class="card p-6 text-red-600">{{ error }}</div> <div v-else-if="error" class="card p-6 text-red-600">{{ error }}</div>
<template v-else> <template v-else>
<div v-if="totals" class="grid grid-cols-2 md:grid-cols-4 gap-3"> <div v-if="totals" class="grid grid-cols-2 md:grid-cols-5 gap-3">
<div class="card p-5"> <div class="card p-5">
<div class="text-xs text-gray-500 uppercase tracking-wider">Всего лидов</div> <div class="text-xs text-gray-500 uppercase tracking-wider">Всего лидов</div>
<div class="text-3xl font-semibold tracking-tight mt-2 tabular-nums"> <div class="text-3xl font-semibold tracking-tight mt-2 tabular-nums">
@ -81,6 +123,13 @@ function score(v: number) {
</div> </div>
<div class="text-xs text-gray-500 mt-2">в работе: {{ totals.in_progress }}</div> <div class="text-xs text-gray-500 mt-2">в работе: {{ totals.in_progress }}</div>
</div> </div>
<div class="card p-5">
<div class="text-xs text-gray-500 uppercase tracking-wider">Прогноз</div>
<div class="text-3xl font-semibold tracking-tight mt-2 tabular-nums text-sky-700">
{{ money(forecast) }}
</div>
<div class="text-xs text-gray-500 mt-2">взвешенный по стадиям</div>
</div>
<div class="card p-5"> <div class="card p-5">
<div class="text-xs text-gray-500 uppercase tracking-wider">Выручка</div> <div class="text-xs text-gray-500 uppercase tracking-wider">Выручка</div>
<div class="text-3xl font-semibold tracking-tight mt-2 tabular-nums text-emerald-700"> <div class="text-3xl font-semibold tracking-tight mt-2 tabular-nums text-emerald-700">
@ -117,6 +166,66 @@ function score(v: number) {
</div> </div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div class="card p-5">
<div class="flex items-center justify-between">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
Воронка
</h2>
<span class="text-xs text-gray-500">{{ funnel.reduce((s, f) => s + f.count, 0) }} лидов</span>
</div>
<div class="mt-4 space-y-2">
<div v-for="f in funnel" :key="f.stage" class="flex items-center gap-3 text-sm">
<div class="w-20 text-xs text-gray-600">{{ stageLabels[f.stage] }}</div>
<div class="flex-1 h-7 bg-gray-50 rounded-md overflow-hidden">
<div
class="h-full rounded-md transition-all flex items-center px-2 text-xs font-medium tabular-nums"
:class="stageBg[f.stage]"
:style="{
width:
(f.count === 0 ? 0 : Math.max(4, (f.count / funnelMax) * 100)) + '%',
}"
>
<span v-if="f.count > 0" class="text-gray-800/80">{{ f.count }}</span>
</div>
</div>
<div class="w-24 text-right text-xs text-gray-500 tabular-nums">
{{ money(f.amount) }}
</div>
</div>
</div>
</div>
<div class="card p-5">
<div class="flex items-center justify-between">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
Активность · 14 дней
</h2>
<span class="text-xs text-gray-500">{{ timelineSum }} новых</span>
</div>
<div class="mt-4 flex items-end gap-1 h-32">
<div
v-for="t in timeline"
:key="t.date"
class="flex-1 flex flex-col items-center gap-1 group"
:title="`${t.date}: ${t.count}`"
>
<div class="text-[10px] tabular-nums text-gray-400 opacity-0 group-hover:opacity-100 transition">
{{ t.count }}
</div>
<div
class="w-full rounded-sm bg-gray-200 group-hover:bg-gray-900 transition-all"
:style="{ height: Math.max(2, (t.count / timelineMax) * 100) + '%' }"
></div>
</div>
</div>
<div class="mt-2 flex justify-between text-[10px] text-gray-400 tabular-nums">
<span>{{ timeline[0]?.date.slice(5) }}</span>
<span>{{ timeline[timeline.length - 1]?.date.slice(5) }}</span>
</div>
</div>
</div>
<div <div
v-if="managers.length === 0" v-if="managers.length === 0"
class="card p-16 text-center text-gray-500" class="card p-16 text-center text-gray-500"
@ -129,6 +238,7 @@ function score(v: number) {
<tr> <tr>
<th>Менеджер</th> <th>Менеджер</th>
<th class="text-right">Лиды</th> <th class="text-right">Лиды</th>
<th>Доля</th>
<th class="text-right">🔥 / 🌡 / ❄</th> <th class="text-right">🔥 / 🌡 / ❄</th>
<th class="text-right">Won / Lost</th> <th class="text-right">Won / Lost</th>
<th class="text-right">Конв.</th> <th class="text-right">Конв.</th>
@ -157,6 +267,14 @@ function score(v: number) {
</div> </div>
</td> </td>
<td class="px-4 py-3 text-right tabular-nums">{{ m.total }}</td> <td class="px-4 py-3 text-right tabular-nums">{{ m.total }}</td>
<td class="px-4 py-3 min-w-[100px]">
<div class="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-gray-900 rounded-full"
:style="{ width: (m.total / managerBarMax) * 100 + '%' }"
></div>
</div>
</td>
<td class="px-4 py-3 text-right text-gray-500 tabular-nums"> <td class="px-4 py-3 text-right text-gray-500 tabular-nums">
{{ m.hot }} / {{ m.warm }} / {{ m.cold }} {{ m.hot }} / {{ m.warm }} / {{ m.cold }}
</td> </td>