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:
parent
12cf6ba713
commit
0950c9f755
@ -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 };
|
||||||
|
|||||||
@ -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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
99
leadscore/frontend/src/components/KanbanBoard.vue
Normal file
99
leadscore/frontend/src/components/KanbanBoard.vue
Normal 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>
|
||||||
@ -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>
|
||||||
|
|||||||
14
leadscore/frontend/src/components/Skeleton.vue
Normal file
14
leadscore/frontend/src/components/Skeleton.vue
Normal 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>
|
||||||
57
leadscore/frontend/src/components/ToastHost.vue
Normal file
57
leadscore/frontend/src/components/ToastHost.vue
Normal 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>
|
||||||
34
leadscore/frontend/src/composables/useToast.ts
Normal file
34
leadscore/frontend/src/composables/useToast.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user