Expand statistics dashboard and fix activity chart

Backend (/stats/managers):
- sources: aggregated count and won per source value
- types: inbound vs outbound totals, conversion %, won amount
- industries: top-10 by lead count with won and pipeline
- tasksPerManager: total, done, overdue, completion %
- cycle: avg days from create to won and to lost

Frontend (StatsView):
- Activity bar chart rewritten in SVG; previous CSS percentage heights
  collapsed because the flex-col container had no fixed height
- New widgets:
  - Источники (per-source horizontal bars)
  - Inbound vs Outbound with won/lost stacked bar
  - Цикл сделки (avg days to won / lost)
  - Топ отраслей (top-10 industries)
  - Задачи менеджеров (completion rate progress bars)
- All loaded from the single /stats/managers call

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ask 2026-05-19 17:21:02 +05:00
parent ba121a6bfc
commit ef92acdd3f
3 changed files with 413 additions and 23 deletions

View File

@ -151,6 +151,108 @@ export async function statsRoutes(app: FastifyInstance) {
return { date: `${y}-${m}-${day}`, count: Number(r.count) }; return { date: `${y}-${m}-${day}`, count: Number(r.count) };
}); });
return { managers, totals, funnel, forecast: Math.round(forecast), timeline }; const bySourceRes = await query<{ source: string | null; lead_type: string; count: string; won: string }>(
`SELECT source, lead_type, COUNT(*)::text AS count,
COUNT(*) FILTER (WHERE deal_stage='won')::text AS won
FROM leads
GROUP BY source, lead_type`,
);
const sources = bySourceRes.rows.map((r) => ({
source: r.source ?? 'n/a',
lead_type: r.lead_type,
count: Number(r.count),
won: Number(r.won),
}));
const byTypeRes = await query<{ lead_type: string; total: string; won: string; lost: string; amount: string }>(
`SELECT lead_type,
COUNT(*)::text AS total,
COUNT(*) FILTER (WHERE deal_stage='won')::text AS won,
COUNT(*) FILTER (WHERE deal_stage='lost')::text AS lost,
COALESCE(SUM(expected_amount) FILTER (WHERE deal_stage='won'),0)::text AS amount
FROM leads
GROUP BY lead_type`,
);
const types = byTypeRes.rows.map((r) => {
const won = Number(r.won);
const lost = Number(r.lost);
const decided = won + lost;
return {
lead_type: r.lead_type,
total: Number(r.total),
won,
lost,
won_amount: Number(r.amount),
conversion_rate: decided === 0 ? 0 : Math.round((won / decided) * 1000) / 10,
};
});
const byIndustryRes = await query<{ industry: string; count: string; won: string; pipeline: string }>(
`SELECT COALESCE(NULLIF(TRIM(industry), ''), 'не указана') AS industry,
COUNT(*)::text AS count,
COUNT(*) FILTER (WHERE deal_stage='won')::text AS won,
COALESCE(SUM(expected_amount) FILTER (WHERE deal_stage NOT IN ('won','lost')),0)::text AS pipeline
FROM leads
GROUP BY 1
ORDER BY COUNT(*) DESC
LIMIT 10`,
);
const industries = byIndustryRes.rows.map((r) => ({
industry: r.industry,
count: Number(r.count),
won: Number(r.won),
pipeline: Number(r.pipeline),
}));
const tasksRes = await query<{
assigned_to: string;
total: string;
done: string;
overdue: string;
}>(
`SELECT assigned_to,
COUNT(*)::text AS total,
COUNT(*) FILTER (WHERE completed_at IS NOT NULL)::text AS done,
COUNT(*) FILTER (WHERE completed_at IS NULL AND due_date < CURRENT_DATE)::text AS overdue
FROM lead_tasks
GROUP BY assigned_to`,
);
const tasksPerManager = tasksRes.rows.map((r) => {
const total = Number(r.total);
const done = Number(r.done);
return {
manager: r.assigned_to,
total,
done,
overdue: Number(r.overdue),
completion_rate: total === 0 ? 0 : Math.round((done / total) * 1000) / 10,
};
});
const timeInStageRes = await query<{ avg_days_to_won: string | null; avg_days_to_lost: string | null }>(
`SELECT
AVG(EXTRACT(EPOCH FROM (updated_at - created_at)) / 86400)
FILTER (WHERE deal_stage = 'won')::numeric(10,1)::text AS avg_days_to_won,
AVG(EXTRACT(EPOCH FROM (updated_at - created_at)) / 86400)
FILTER (WHERE deal_stage = 'lost')::numeric(10,1)::text AS avg_days_to_lost
FROM leads`,
);
const cycle = {
avg_days_to_won: Number(timeInStageRes.rows[0]?.avg_days_to_won ?? 0) || 0,
avg_days_to_lost: Number(timeInStageRes.rows[0]?.avg_days_to_lost ?? 0) || 0,
};
return {
managers,
totals,
funnel,
forecast: Math.round(forecast),
timeline,
sources,
types,
industries,
tasksPerManager,
cycle,
};
}); });
} }

View File

@ -44,6 +44,42 @@ export interface TimelineEntry {
count: number; count: number;
} }
export interface SourceEntry {
source: string;
lead_type: string;
count: number;
won: number;
}
export interface TypeEntry {
lead_type: string;
total: number;
won: number;
lost: number;
won_amount: number;
conversion_rate: number;
}
export interface IndustryEntry {
industry: string;
count: number;
won: number;
pipeline: number;
}
export interface ManagerTaskStat {
manager: string;
total: number;
done: number;
overdue: number;
completion_rate: number;
}
export interface CycleStat {
avg_days_to_won: number;
avg_days_to_lost: number;
}
export function getManagerStats() { export function getManagerStats() {
return apiFetch<{ return apiFetch<{
managers: ManagerStat[]; managers: ManagerStat[];
@ -51,5 +87,10 @@ export function getManagerStats() {
funnel: FunnelEntry[]; funnel: FunnelEntry[];
forecast: number; forecast: number;
timeline: TimelineEntry[]; timeline: TimelineEntry[];
sources: SourceEntry[];
types: TypeEntry[];
industries: IndustryEntry[];
tasksPerManager: ManagerTaskStat[];
cycle: CycleStat;
}>('/stats/managers'); }>('/stats/managers');
} }

View File

@ -3,10 +3,15 @@ import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { import {
getManagerStats, getManagerStats,
type CycleStat,
type FunnelEntry, type FunnelEntry,
type IndustryEntry,
type ManagerStat, type ManagerStat,
type ManagerTaskStat,
type ManagerTotals, type ManagerTotals,
type SourceEntry,
type TimelineEntry, type TimelineEntry,
type TypeEntry,
} from '@/api/stats'; } from '@/api/stats';
import Skeleton from '@/components/Skeleton.vue'; import Skeleton from '@/components/Skeleton.vue';
@ -21,6 +26,12 @@ const totals = ref<ManagerTotals | null>(null);
const funnel = ref<FunnelEntry[]>([]); const funnel = ref<FunnelEntry[]>([]);
const forecast = ref(0); const forecast = ref(0);
const timeline = ref<TimelineEntry[]>([]); const timeline = ref<TimelineEntry[]>([]);
const sources = ref<SourceEntry[]>([]);
const types = ref<TypeEntry[]>([]);
const industries = ref<IndustryEntry[]>([]);
const tasksPerManager = ref<ManagerTaskStat[]>([]);
const cycle = ref<CycleStat>({ avg_days_to_won: 0, avg_days_to_lost: 0 });
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
@ -34,6 +45,11 @@ async function load() {
funnel.value = res.funnel; funnel.value = res.funnel;
forecast.value = res.forecast; forecast.value = res.forecast;
timeline.value = res.timeline; timeline.value = res.timeline;
sources.value = res.sources;
types.value = res.types;
industries.value = res.industries;
tasksPerManager.value = res.tasksPerManager;
cycle.value = res.cycle;
} catch (e) { } catch (e) {
error.value = (e as Error).message; error.value = (e as Error).message;
} finally { } finally {
@ -69,6 +85,13 @@ const stageBg: Record<string, string> = {
lost: 'bg-red-300', lost: 'bg-red-300',
}; };
const sourceLabels: Record<string, string> = {
cold: 'Холодный',
referral: 'Реферал',
website: 'Сайт',
'n/a': 'Outbound',
};
const funnelMax = computed(() => const funnelMax = computed(() =>
Math.max(1, ...funnel.value.filter((f) => f.stage !== 'lost').map((f) => f.count)), Math.max(1, ...funnel.value.filter((f) => f.stage !== 'lost').map((f) => f.count)),
); );
@ -79,6 +102,53 @@ const timelineSum = computed(() => timeline.value.reduce((s, t) => s + t.count,
const managerBarMax = computed(() => const managerBarMax = computed(() =>
Math.max(1, ...managers.value.map((m) => m.total)), Math.max(1, ...managers.value.map((m) => m.total)),
); );
const sourcesAgg = computed(() => {
const map = new Map<string, { count: number; won: number }>();
for (const s of sources.value) {
const cur = map.get(s.source) ?? { count: 0, won: 0 };
cur.count += s.count;
cur.won += s.won;
map.set(s.source, cur);
}
return [...map.entries()]
.map(([source, v]) => ({ source, ...v }))
.sort((a, b) => b.count - a.count);
});
const sourcesMax = computed(() => Math.max(1, ...sourcesAgg.value.map((s) => s.count)));
const industriesMax = computed(() => Math.max(1, ...industries.value.map((i) => i.count)));
const tasksPerManagerMax = computed(() => Math.max(1, ...tasksPerManager.value.map((t) => t.total)));
const chartW = 800;
const chartH = 120;
const chartPad = { top: 8, bottom: 18, left: 24, right: 8 };
const innerW = chartW - chartPad.left - chartPad.right;
const innerH = chartH - chartPad.top - chartPad.bottom;
const barWidth = computed(() =>
timeline.value.length === 0 ? 0 : innerW / timeline.value.length - 4,
);
function barX(i: number) {
return chartPad.left + (i * innerW) / Math.max(1, timeline.value.length);
}
function barH(count: number) {
if (count === 0) return 0;
return Math.max(2, (count / timelineMax.value) * innerH);
}
function barY(count: number) {
return chartPad.top + innerH - barH(count);
}
function tickX(date: string, i: number): { x: number; label: string } | null {
if (timeline.value.length === 0) return null;
if (i !== 0 && i !== timeline.value.length - 1 && i !== Math.floor(timeline.value.length / 2))
return null;
return { x: barX(i) + barWidth.value / 2, label: date.slice(5) };
}
</script> </script>
<template> <template>
@ -88,12 +158,7 @@ const managerBarMax = computed(() =>
<h1 class="text-2xl font-semibold tracking-tight">Статистика менеджеров</h1> <h1 class="text-2xl font-semibold tracking-tight">Статистика менеджеров</h1>
<p class="text-sm text-gray-500 mt-1">Сводка по всей команде продаж</p> <p class="text-sm text-gray-500 mt-1">Сводка по всей команде продаж</p>
</div> </div>
<button <button type="button" class="btn-secondary" :disabled="loading" @click="load">
type="button"
class="btn-secondary"
:disabled="loading"
@click="load"
>
Обновить Обновить
</button> </button>
</div> </div>
@ -172,7 +237,9 @@ const managerBarMax = computed(() =>
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest"> <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
Воронка Воронка
</h2> </h2>
<span class="text-xs text-gray-500">{{ funnel.reduce((s, f) => s + f.count, 0) }} лидов</span> <span class="text-xs text-gray-500">
{{ funnel.reduce((s, f) => s + f.count, 0) }} лидов
</span>
</div> </div>
<div class="mt-4 space-y-2"> <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 v-for="f in funnel" :key="f.stage" class="flex items-center gap-3 text-sm">
@ -203,26 +270,206 @@ const managerBarMax = computed(() =>
</h2> </h2>
<span class="text-xs text-gray-500">{{ timelineSum }} новых</span> <span class="text-xs text-gray-500">{{ timelineSum }} новых</span>
</div> </div>
<div class="mt-4 flex items-end gap-1 h-32"> <svg
<div v-if="timeline.length > 0"
v-for="t in timeline" :viewBox="`0 0 ${chartW} ${chartH}`"
:key="t.date" class="mt-3 w-full h-32"
class="flex-1 flex flex-col items-center gap-1 group" preserveAspectRatio="none"
:title="`${t.date}: ${t.count}`" >
<line
v-for="(_, i) in 4"
:key="i"
:x1="chartPad.left"
:x2="chartW - chartPad.right"
:y1="chartPad.top + (innerH * i) / 3"
:y2="chartPad.top + (innerH * i) / 3"
stroke="#f3f4f6"
stroke-width="1"
/>
<g v-for="(t, i) in timeline" :key="t.date">
<rect
:x="barX(i) + 2"
:y="barY(t.count)"
:width="Math.max(0, barWidth)"
:height="barH(t.count)"
rx="2"
fill="#1f2937"
opacity="0.85"
>
<title>{{ t.date }}: {{ t.count }}</title>
</rect>
<text
v-if="t.count > 0"
:x="barX(i) + 2 + barWidth / 2"
:y="barY(t.count) - 3"
font-size="9"
text-anchor="middle"
fill="#6b7280"
> >
<div class="text-[10px] tabular-nums text-gray-400 opacity-0 group-hover:opacity-100 transition">
{{ t.count }} {{ t.count }}
</text>
</g>
<text
v-for="(t, i) in timeline"
:key="`x-${t.date}`"
:x="barX(i) + 2 + barWidth / 2"
:y="chartH - 4"
font-size="9"
text-anchor="middle"
fill="#9ca3af"
>
<template v-if="i === 0 || i === timeline.length - 1 || i === Math.floor(timeline.length / 2)">
{{ t.date.slice(5) }}
</template>
</text>
</svg>
<div v-else class="text-xs text-gray-400 mt-4">Нет данных за период.</div>
</div> </div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 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">
{{ sourcesAgg.reduce((s, x) => s + x.count, 0) }} лидов
</span>
</div>
<div v-if="sourcesAgg.length === 0" class="text-xs text-gray-400 mt-4">—</div>
<ul v-else class="mt-3 space-y-2">
<li v-for="s in sourcesAgg" :key="s.source" class="text-sm">
<div class="flex items-center justify-between text-xs text-gray-600 mb-1">
<span>{{ sourceLabels[s.source] ?? s.source }}</span>
<span class="tabular-nums">
{{ s.count }}
<span v-if="s.won > 0" class="text-emerald-600">· won {{ s.won }}</span>
</span>
</div>
<div class="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div <div
class="w-full rounded-sm bg-gray-200 group-hover:bg-gray-900 transition-all" class="h-full bg-gray-900 rounded-full"
:style="{ height: Math.max(2, (t.count / timelineMax) * 100) + '%' }" :style="{ width: (s.count / sourcesMax) * 100 + '%' }"
></div> ></div>
</div> </div>
</li>
</ul>
</div> </div>
<div class="mt-2 flex justify-between text-[10px] text-gray-400 tabular-nums">
<span>{{ timeline[0]?.date.slice(5) }}</span> <div class="card p-5">
<span>{{ timeline[timeline.length - 1]?.date.slice(5) }}</span> <div class="flex items-center justify-between">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
Inbound vs Outbound
</h2>
</div> </div>
<div v-if="types.length === 0" class="text-xs text-gray-400 mt-4">—</div>
<div v-else class="mt-3 space-y-3">
<div v-for="t in types" :key="t.lead_type" class="text-sm">
<div class="flex items-center justify-between mb-1">
<span class="font-medium capitalize">{{ t.lead_type }}</span>
<span class="text-xs text-gray-500 tabular-nums">
{{ t.total }} лидов · конв. {{ t.conversion_rate }}%
</span>
</div>
<div class="flex h-2 rounded-full overflow-hidden bg-gray-100">
<div
class="bg-emerald-400"
:style="{ width: t.total === 0 ? 0 : (t.won / t.total) * 100 + '%' }"
:title="`won: ${t.won}`"
></div>
<div
class="bg-red-300"
:style="{ width: t.total === 0 ? 0 : (t.lost / t.total) * 100 + '%' }"
:title="`lost: ${t.lost}`"
></div>
</div>
<div class="text-xs text-gray-500 mt-1 tabular-nums">
Выручка: <span class="text-emerald-700">{{ money(t.won_amount) }}</span>
</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">
Цикл сделки
</h2>
</div>
<div class="mt-4 space-y-3 text-sm">
<div>
<div class="text-xs text-gray-500">Среднее до Won</div>
<div class="text-2xl font-semibold tabular-nums text-emerald-700 mt-1">
{{ cycle.avg_days_to_won.toFixed(1) }} <span class="text-base">дн.</span>
</div>
</div>
<div>
<div class="text-xs text-gray-500">Среднее до Lost</div>
<div class="text-2xl font-semibold tabular-nums text-red-600 mt-1">
{{ cycle.avg_days_to_lost.toFixed(1) }} <span class="text-base">дн.</span>
</div>
</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">{{ industries.length }} групп</span>
</div>
<div v-if="industries.length === 0" class="text-xs text-gray-400 mt-4">—</div>
<ul v-else class="mt-3 space-y-2">
<li v-for="i in industries" :key="i.industry" class="text-sm">
<div class="flex items-center justify-between text-xs text-gray-600 mb-1">
<span class="truncate">{{ i.industry }}</span>
<span class="tabular-nums whitespace-nowrap">
{{ i.count }}
<span v-if="i.won > 0" class="text-emerald-600">· {{ i.won }} won</span>
<span v-if="i.pipeline > 0" class="text-gray-500">· {{ money(i.pipeline) }}</span>
</span>
</div>
<div class="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-sky-400 rounded-full"
:style="{ width: (i.count / industriesMax) * 100 + '%' }"
></div>
</div>
</li>
</ul>
</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">
Задачи менеджеров
</h2>
</div>
<div v-if="tasksPerManager.length === 0" class="text-xs text-gray-400 mt-4">Задач пока нет.</div>
<ul v-else class="mt-3 space-y-2">
<li v-for="t in tasksPerManager" :key="t.manager" class="text-sm">
<div class="flex items-center justify-between text-xs text-gray-600 mb-1">
<span class="font-medium text-gray-800">{{ t.manager }}</span>
<span class="tabular-nums">
{{ t.done }} / {{ t.total }}
<span v-if="t.overdue > 0" class="text-red-600">· просроч. {{ t.overdue }}</span>
</span>
</div>
<div class="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-emerald-400 rounded-full"
:style="{ width: t.completion_rate + '%' }"
></div>
</div>
<div class="text-[10px] text-gray-500 mt-0.5 tabular-nums text-right">
{{ t.completion_rate }}% выполнено
</div>
</li>
</ul>
</div> </div>
</div> </div>