Add ROP-only manager stats and role-based access control

Backend:
- auth: extend AUTH_USERS format to user#️⃣role (backcompat: 2 fields → role=user)
- routes/stats: GET /stats/managers (rop only) aggregates per-manager KPIs:
  total, hot/warm/cold, won/lost/in_progress, conversion %, avg_score,
  pipeline_amount, won_amount, overdue, new_7d, new_30d
- routes/leads LIST: scope to created_by=user.sub for managers; rop sees
  all and may filter via ?created_by=
- routes/leads LIST: add sort options (created_*, amount_*, next_action_*)
- routes/leads DELETE: rop only

Frontend:
- stores/leads: page/pageSize, sort, createdBy, reset() helper
- components/LeadsTable: shared filter+sort+table+pagination component
- views/LeadsListView: uses shared LeadsTable
- views/ManagerLeadsView: rop drill-down to one manager's leads
- views/StatsView: KPI cards + per-manager table, rows link to drill-down
- api/stats: getManagerStats() client
- App.vue: Статистика link visible only to rop
- router: ropOnly meta on stats routes; redirect non-rop to /leads
- LeadCardView + LeadsTable: hide delete button for non-rop

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ask 2026-05-19 15:38:24 +05:00
parent 2d2b8784ec
commit f38f61b0f6
16 changed files with 8935 additions and 178 deletions

3885
leadscore/backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,16 +4,29 @@ import { config } from './config.js';
import { AppError } from './errors.js';
import type { JwtUser } from './types.js';
function parseAuthUsers(raw: string): Map<string, string> {
const map = new Map<string, string>();
interface AuthUserEntry {
hash: string;
role: string;
}
function parseAuthUsers(raw: string): Map<string, AuthUserEntry> {
const map = new Map<string, AuthUserEntry>();
for (const entry of raw.split(',')) {
const trimmed = entry.trim();
if (!trimmed) continue;
const idx = trimmed.indexOf(':');
if (idx === -1) continue;
const username = trimmed.slice(0, idx);
const hash = trimmed.slice(idx + 1);
if (username && hash) map.set(username, hash);
const parts = trimmed.split(':');
if (parts.length < 2) continue;
const username = parts[0];
let hash: string;
let role: string;
if (parts.length === 2) {
hash = parts[1];
role = username;
} else {
role = parts[parts.length - 1];
hash = parts.slice(1, -1).join(':');
}
if (username && hash) map.set(username, { hash, role });
}
return map;
}
@ -24,11 +37,11 @@ export async function verifyCredentials(
username: string,
password: string,
): Promise<JwtUser | null> {
const hash = users.get(username);
if (!hash) return null;
const ok = await bcrypt.compare(password, hash);
const entry = users.get(username);
if (!entry) return null;
const ok = await bcrypt.compare(password, entry.hash);
if (!ok) return null;
return { sub: username, role: username };
return { sub: username, role: entry.role };
}
export async function requireAuth(

View File

@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { query } from '../db.js';
import { calculateScore } from '../scoring.js';
import { AppError } from '../errors.js';
import { requireAuth } from '../auth.js';
import { requireAuth, requireRole } from '../auth.js';
import {
DEAL_STAGES,
type DealStage,
@ -168,12 +168,22 @@ export async function leadRoutes(app: FastifyInstance) {
sort?: string;
limit?: string;
offset?: string;
created_by?: string;
};
}>('/leads', async (req) => {
const { status, lead_type, deal_stage, sort, limit, offset } = req.query;
const user = await requireAuth(app, req);
const { status, lead_type, deal_stage, sort, limit, offset, created_by } = 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 (status && status !== 'all') {
if (!['cold', 'warm', 'hot'].includes(status))
throw AppError.validation('invalid status');
@ -194,7 +204,17 @@ export async function leadRoutes(app: FastifyInstance) {
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const orderSql = sort === 'score_asc' ? 'score ASC' : 'score DESC';
const orderMap: Record<string, string> = {
score_desc: 'score DESC',
score_asc: 'score ASC',
created_desc: 'created_at DESC',
created_asc: 'created_at ASC',
amount_desc: 'expected_amount DESC NULLS LAST',
amount_asc: 'expected_amount ASC NULLS LAST',
next_action_asc: 'next_action_date ASC NULLS LAST',
next_action_desc: 'next_action_date DESC NULLS LAST',
};
const orderSql = orderMap[sort ?? ''] ?? 'score DESC';
const lim = Math.min(Math.max(Number(limit ?? 50), 1), 500);
const off = Math.max(Number(offset ?? 0), 0);
@ -436,6 +456,8 @@ export async function leadRoutes(app: FastifyInstance) {
// DELETE
app.delete<{ Params: { id: string } }>('/leads/:id', async (req, reply) => {
const user = await requireAuth(app, req);
requireRole(user, 'rop');
const { rowCount } = await query(`DELETE FROM leads WHERE id = $1`, [req.params.id]);
if (!rowCount) throw AppError.notFound('Lead not found');
return reply.code(204).send();

View File

@ -0,0 +1,111 @@
import type { FastifyInstance } from 'fastify';
import { query } from '../db.js';
import { requireAuth, requireRole } from '../auth.js';
interface ManagerStatRow {
manager: string;
total: string;
hot: string;
warm: string;
cold: string;
won: string;
lost: string;
in_progress: string;
avg_score: string | null;
pipeline_amount: string | null;
won_amount: string | null;
overdue: string;
new_7d: string;
new_30d: string;
}
export async function statsRoutes(app: FastifyInstance) {
app.get('/stats/managers', async (req) => {
const user = await requireAuth(app, req);
requireRole(user, 'rop');
const sql = `
SELECT
created_by AS manager,
COUNT(*)::text AS total,
COUNT(*) FILTER (WHERE status = 'hot')::text AS hot,
COUNT(*) FILTER (WHERE status = 'warm')::text AS warm,
COUNT(*) FILTER (WHERE status = 'cold')::text AS cold,
COUNT(*) FILTER (WHERE deal_stage = 'won')::text AS won,
COUNT(*) FILTER (WHERE deal_stage = 'lost')::text AS lost,
COUNT(*) FILTER (WHERE deal_stage NOT IN ('won','lost'))::text AS in_progress,
AVG(score)::numeric(10,2)::text AS avg_score,
COALESCE(SUM(expected_amount) FILTER (WHERE deal_stage NOT IN ('won','lost')), 0)::text AS pipeline_amount,
COALESCE(SUM(expected_amount) FILTER (WHERE deal_stage = 'won'), 0)::text AS won_amount,
COUNT(*) FILTER (
WHERE next_action_date IS NOT NULL
AND next_action_date < CURRENT_DATE
AND deal_stage NOT IN ('won','lost')
)::text AS overdue,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days')::text AS new_7d,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '30 days')::text AS new_30d
FROM leads
GROUP BY created_by
ORDER BY total DESC, manager ASC
`;
const res = await query<ManagerStatRow>(sql);
const managers = res.rows.map((r) => {
const won = Number(r.won);
const lost = Number(r.lost);
const decided = won + lost;
return {
manager: r.manager,
total: Number(r.total),
hot: Number(r.hot),
warm: Number(r.warm),
cold: Number(r.cold),
won,
lost,
in_progress: Number(r.in_progress),
avg_score: r.avg_score === null ? 0 : Number(r.avg_score),
pipeline_amount: Number(r.pipeline_amount ?? 0),
won_amount: Number(r.won_amount ?? 0),
overdue: Number(r.overdue),
new_7d: Number(r.new_7d),
new_30d: Number(r.new_30d),
conversion_rate: decided === 0 ? 0 : Math.round((won / decided) * 1000) / 10,
};
});
const totals = managers.reduce(
(acc, m) => {
acc.total += m.total;
acc.hot += m.hot;
acc.warm += m.warm;
acc.cold += m.cold;
acc.won += m.won;
acc.lost += m.lost;
acc.in_progress += m.in_progress;
acc.pipeline_amount += m.pipeline_amount;
acc.won_amount += m.won_amount;
acc.overdue += m.overdue;
acc.new_7d += m.new_7d;
acc.new_30d += m.new_30d;
return acc;
},
{
total: 0,
hot: 0,
warm: 0,
cold: 0,
won: 0,
lost: 0,
in_progress: 0,
pipeline_amount: 0,
won_amount: 0,
overdue: 0,
new_7d: 0,
new_30d: 0,
},
);
return { managers, totals };
});
}

View File

@ -5,6 +5,7 @@ import { AppError } from './errors.js';
import { authRoutes } from './routes/auth.js';
import { leadRoutes } from './routes/leads.js';
import { healthRoutes } from './routes/health.js';
import { statsRoutes } from './routes/stats.js';
async function buildApp() {
const app = Fastify({
@ -36,6 +37,7 @@ async function buildApp() {
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(leadRoutes);
await app.register(statsRoutes);
return app;
}

4371
leadscore/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,13 @@ function logout() {
>
+ Новый лид
</RouterLink>
<RouterLink
v-if="auth.isRop"
:to="{ name: 'stats' }"
class="text-sm text-gray-600 hover:text-gray-900"
>
Статистика
</RouterLink>
</div>
<div class="flex items-center gap-4">
<span class="text-sm text-gray-500">

View File

@ -91,13 +91,35 @@ export interface HistoryEntry {
changed_by: string;
}
export type LeadSort =
| 'score_desc'
| 'score_asc'
| 'created_desc'
| 'created_asc'
| 'amount_desc'
| 'amount_asc'
| 'next_action_asc'
| 'next_action_desc';
export const LEAD_SORT_LABELS: Record<LeadSort, string> = {
score_desc: 'Score ↓',
score_asc: 'Score ↑',
created_desc: 'Сначала новые',
created_asc: 'Сначала старые',
amount_desc: 'Сумма ↓',
amount_asc: 'Сумма ↑',
next_action_asc: 'Касание раньше',
next_action_desc: 'Касание позже',
};
export interface LeadListQuery {
status?: LeadStatus | 'all';
lead_type?: LeadType | 'all';
deal_stage?: DealStage | 'all';
sort?: 'score_desc' | 'score_asc';
sort?: LeadSort;
limit?: number;
offset?: number;
created_by?: string;
}
export interface CreateLeadBody {

View File

@ -0,0 +1,40 @@
import { apiFetch } from './client';
export interface ManagerStat {
manager: string;
total: number;
hot: number;
warm: number;
cold: number;
won: number;
lost: number;
in_progress: number;
avg_score: number;
pipeline_amount: number;
won_amount: number;
overdue: number;
new_7d: number;
new_30d: number;
conversion_rate: number;
}
export interface ManagerTotals {
total: number;
hot: number;
warm: number;
cold: number;
won: number;
lost: number;
in_progress: number;
pipeline_amount: number;
won_amount: number;
overdue: number;
new_7d: number;
new_30d: number;
}
export function getManagerStats() {
return apiFetch<{ managers: ManagerStat[]; totals: ManagerTotals }>(
'/stats/managers',
);
}

View File

@ -0,0 +1,196 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { useLeadsStore } from '@/stores/leads';
import ScoreBadge from '@/components/ScoreBadge.vue';
import FilterBar from '@/components/FilterBar.vue';
import {
DEAL_STAGE_LABELS,
LEAD_SORT_LABELS,
deleteLead,
type DealStage,
type LeadSort,
} from '@/api/leads';
const store = useLeadsStore();
const auth = useAuthStore();
const router = useRouter();
const sortOptions: LeadSort[] = [
'score_desc',
'score_asc',
'created_desc',
'created_asc',
'amount_desc',
'amount_asc',
'next_action_asc',
'next_action_desc',
];
function formatDate(s: string | null) {
if (!s) return '—';
return new Date(s.length === 10 ? s + 'T00:00:00' : s).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function formatMoney(v: number | null) {
if (v === null) return '—';
return v.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' ₸';
}
const stageClass: Record<DealStage, string> = {
new: 'bg-gray-100 text-gray-700',
contacted: 'bg-blue-100 text-blue-800',
proposal: 'bg-amber-100 text-amber-800',
won: 'bg-green-100 text-green-800',
lost: 'bg-red-100 text-red-800',
};
async function removeLead(id: string, name: string) {
if (!confirm(`Удалить лида «${name}»?`)) return;
try {
await deleteLead(id);
await store.load();
} catch (e) {
alert((e as Error).message);
}
}
</script>
<template>
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<FilterBar />
<div class="flex items-center gap-2 text-sm">
<label class="text-gray-500">Сортировка:</label>
<select
:value="store.sort"
@change="store.setSort(($event.target as HTMLSelectElement).value as LeadSort)"
class="border border-gray-300 rounded-md px-2 py-1 bg-white"
>
<option v-for="s in sortOptions" :key="s" :value="s">
{{ LEAD_SORT_LABELS[s] }}
</option>
</select>
</div>
</div>
<div v-if="store.loading" class="text-gray-500">Загрузка…</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<div
v-else-if="store.items.length === 0"
class="bg-white rounded-xl border border-gray-200 p-12 text-center text-gray-500"
>
Лидов не найдено.
</div>
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-xs text-gray-500 uppercase">
<tr>
<th class="text-left px-4 py-3 font-medium">Score</th>
<th class="text-left px-4 py-3 font-medium">Контакт</th>
<th class="text-left px-4 py-3 font-medium">Компания</th>
<th class="text-left px-4 py-3 font-medium">Этап</th>
<th class="text-left px-4 py-3 font-medium">Сумма</th>
<th class="text-left px-4 py-3 font-medium">След. касание</th>
<th class="text-left px-4 py-3 font-medium">Тип</th>
<th class="text-left px-4 py-3 font-medium">Автор</th>
<th class="text-right px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
<tr
v-for="lead in store.items"
:key="lead.id"
class="border-t border-gray-100 hover:bg-gray-50 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>{{ 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>{{ 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="text-xs font-medium px-2 py-1 rounded-full"
:class="stageClass[lead.deal_stage]"
>
{{ DEAL_STAGE_LABELS[lead.deal_stage] }}
</span>
</td>
<td class="px-4 py-3 text-gray-700">{{ formatMoney(lead.expected_amount) }}</td>
<td class="px-4 py-3 text-gray-600">{{ formatDate(lead.next_action_date) }}</td>
<td class="px-4 py-3 text-gray-600">
{{ lead.lead_type === 'inbound' ? 'Inbound' : 'Outbound' }}
</td>
<td class="px-4 py-3 text-gray-600">{{ lead.created_by }}</td>
<td class="px-4 py-3 text-right">
<button
v-if="auth.isRop"
type="button"
class="text-red-600 hover:text-red-800 text-sm"
title="Удалить"
@click.stop="removeLead(lead.id, lead.contact_name)"
>
Удалить
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="store.items.length > 0"
class="flex items-center justify-between text-sm text-gray-700"
>
<div>
<label class="mr-2">На странице:</label>
<select
:value="store.pageSize"
@change="store.setPageSize(Number(($event.target as HTMLSelectElement).value))"
class="border border-gray-300 rounded-md px-2 py-1 bg-white"
>
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
<span class="ml-4 text-gray-500">
{{ (store.page - 1) * store.pageSize + 1 }}–{{
Math.min(store.page * store.pageSize, store.total)
}}
из {{ store.total }}
</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="px-3 py-1 rounded-md border border-gray-300 bg-white disabled:opacity-40"
:disabled="store.page <= 1 || store.loading"
@click="store.setPage(store.page - 1)"
>
←
</button>
<span class="px-2">{{ store.page }} / {{ store.totalPages }}</span>
<button
type="button"
class="px-3 py-1 rounded-md border border-gray-300 bg-white disabled:opacity-40"
:disabled="store.page >= store.totalPages || store.loading"
@click="store.setPage(store.page + 1)"
>
→
</button>
</div>
</div>
</div>
</template>

View File

@ -26,6 +26,19 @@ const router = createRouter({
component: () => import('@/views/LeadCardView.vue'),
props: true,
},
{
path: '/stats',
name: 'stats',
component: () => import('@/views/StatsView.vue'),
meta: { ropOnly: true },
},
{
path: '/stats/manager/:manager',
name: 'stats-manager',
component: () => import('@/views/ManagerLeadsView.vue'),
meta: { ropOnly: true },
props: true,
},
],
});
@ -37,6 +50,9 @@ router.beforeEach((to) => {
if (to.name === 'login' && auth.isAuthenticated) {
return { name: 'leads' };
}
if (to.meta.ropOnly && !auth.isRop) {
return { name: 'leads' };
}
});
export default router;

View File

@ -3,6 +3,7 @@ import {
listLeads,
type DealStage,
type LeadListItem,
type LeadSort,
type LeadStatus,
type LeadType,
} from '@/api/leads';
@ -14,6 +15,8 @@ interface State {
error: string | null;
page: number;
pageSize: number;
sort: LeadSort;
createdBy: string | null;
filters: {
status: LeadStatus | 'all';
lead_type: LeadType | 'all';
@ -21,20 +24,29 @@ interface State {
};
}
export const useLeadsStore = defineStore('leads', {
state: (): State => ({
const INITIAL_STATE = (): State => ({
items: [],
total: 0,
loading: false,
error: null,
page: 1,
pageSize: 20,
sort: 'score_desc',
createdBy: null,
filters: { status: 'all', lead_type: 'all', deal_stage: 'all' },
}),
});
export const useLeadsStore = defineStore('leads', {
state: INITIAL_STATE,
getters: {
totalPages: (s): number => Math.max(1, Math.ceil(s.total / s.pageSize)),
},
actions: {
reset(createdBy: string | null = null) {
const fresh = INITIAL_STATE();
Object.assign(this, fresh);
this.createdBy = createdBy;
},
async load() {
this.loading = true;
this.error = null;
@ -43,9 +55,10 @@ export const useLeadsStore = defineStore('leads', {
status: this.filters.status,
lead_type: this.filters.lead_type,
deal_stage: this.filters.deal_stage,
sort: 'score_desc',
sort: this.sort,
limit: this.pageSize,
offset: (this.page - 1) * this.pageSize,
created_by: this.createdBy ?? undefined,
});
this.items = res.data;
this.total = res.total;
@ -70,6 +83,11 @@ export const useLeadsStore = defineStore('leads', {
this.page = 1;
return this.load();
},
setSort(sort: LeadSort) {
this.sort = sort;
this.page = 1;
return this.load();
},
setStatus(status: LeadStatus | 'all') {
this.filters.status = status;
this.page = 1;

View File

@ -2,6 +2,7 @@
import { onMounted, ref, computed, reactive, watch } from 'vue';
import { useRouter } from 'vue-router';
import ScoreBadge from '@/components/ScoreBadge.vue';
import { useAuthStore } from '@/stores/auth';
import {
deleteLead,
getLead,
@ -16,6 +17,7 @@ import {
import { ApiRequestError } from '@/api/client';
const props = defineProps<{ id: string }>();
const auth = useAuthStore();
const lead = ref<Lead | null>(null);
const history = ref<HistoryEntry[]>([]);
@ -343,7 +345,7 @@ onMounted(load);
</ul>
</div>
<div class="flex gap-3">
<div v-if="auth.isRop" class="flex gap-3">
<button
type="button"
class="text-red-600 hover:text-red-800 text-sm"

View File

@ -2,45 +2,14 @@
import { onMounted } from 'vue';
import { RouterLink } from 'vue-router';
import { useLeadsStore } from '@/stores/leads';
import ScoreBadge from '@/components/ScoreBadge.vue';
import FilterBar from '@/components/FilterBar.vue';
import { DEAL_STAGE_LABELS, deleteLead, type DealStage } from '@/api/leads';
import LeadsTable from '@/components/LeadsTable.vue';
const store = useLeadsStore();
onMounted(() => store.load());
function formatDate(s: string | null) {
if (!s) return '—';
return new Date(s.length === 10 ? s + 'T00:00:00' : s).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function formatMoney(v: number | null) {
if (v === null) return '—';
return v.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' ₸';
}
const stageClass: Record<DealStage, string> = {
new: 'bg-gray-100 text-gray-700',
contacted: 'bg-blue-100 text-blue-800',
proposal: 'bg-amber-100 text-amber-800',
won: 'bg-green-100 text-green-800',
lost: 'bg-red-100 text-red-800',
};
async function removeLead(id: string, name: string) {
if (!confirm(`Удалить лида «${name}»?`)) return;
try {
await deleteLead(id);
onMounted(async () => {
store.reset();
await store.load();
} catch (e) {
alert((e as Error).message);
}
}
});
</script>
<template>
@ -54,121 +23,6 @@ async function removeLead(id: string, name: string) {
+ Добавить лида
</RouterLink>
</div>
<FilterBar />
<div v-if="store.loading" class="text-gray-500">Загрузка…</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<div
v-else-if="store.items.length === 0"
class="bg-white rounded-xl border border-gray-200 p-12 text-center text-gray-500"
>
Лидов пока нет. Добавь первого.
</div>
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-xs text-gray-500 uppercase">
<tr>
<th class="text-left px-4 py-3 font-medium">Score</th>
<th class="text-left px-4 py-3 font-medium">Контакт</th>
<th class="text-left px-4 py-3 font-medium">Компания</th>
<th class="text-left px-4 py-3 font-medium">Этап</th>
<th class="text-left px-4 py-3 font-medium">Сумма</th>
<th class="text-left px-4 py-3 font-medium">След. касание</th>
<th class="text-left px-4 py-3 font-medium">Тип</th>
<th class="text-left px-4 py-3 font-medium">Автор</th>
<th class="text-right px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
<tr
v-for="lead in store.items"
:key="lead.id"
class="border-t border-gray-100 hover:bg-gray-50 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>{{ 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>{{ 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="text-xs font-medium px-2 py-1 rounded-full"
:class="stageClass[lead.deal_stage]"
>
{{ DEAL_STAGE_LABELS[lead.deal_stage] }}
</span>
</td>
<td class="px-4 py-3 text-gray-700">{{ formatMoney(lead.expected_amount) }}</td>
<td class="px-4 py-3 text-gray-600">{{ formatDate(lead.next_action_date) }}</td>
<td class="px-4 py-3 text-gray-600">
{{ lead.lead_type === 'inbound' ? 'Inbound' : 'Outbound' }}
</td>
<td class="px-4 py-3 text-gray-600">{{ lead.created_by }}</td>
<td class="px-4 py-3 text-right">
<button
type="button"
class="text-red-600 hover:text-red-800 text-sm"
title="Удалить"
@click.stop="removeLead(lead.id, lead.contact_name)"
>
Удалить
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="store.items.length > 0"
class="flex items-center justify-between text-sm text-gray-700"
>
<div>
<label class="mr-2">На странице:</label>
<select
:value="store.pageSize"
@change="store.setPageSize(Number(($event.target as HTMLSelectElement).value))"
class="border border-gray-300 rounded-md px-2 py-1 bg-white"
>
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
<span class="ml-4 text-gray-500">
{{ (store.page - 1) * store.pageSize + 1 }}–{{
Math.min(store.page * store.pageSize, store.total)
}}
из {{ store.total }}
</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="px-3 py-1 rounded-md border border-gray-300 bg-white disabled:opacity-40"
:disabled="store.page <= 1 || store.loading"
@click="store.setPage(store.page - 1)"
>
←
</button>
<span class="px-2">{{ store.page }} / {{ store.totalPages }}</span>
<button
type="button"
class="px-3 py-1 rounded-md border border-gray-300 bg-white disabled:opacity-40"
:disabled="store.page >= store.totalPages || store.loading"
@click="store.setPage(store.page + 1)"
>
→
</button>
</div>
</div>
<LeadsTable />
</div>
</template>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { useLeadsStore } from '@/stores/leads';
import LeadsTable from '@/components/LeadsTable.vue';
const route = useRoute();
const store = useLeadsStore();
async function loadFor(manager: string) {
store.reset(manager);
await store.load();
}
onMounted(() => loadFor(String(route.params.manager)));
watch(
() => route.params.manager,
(m) => {
if (m) loadFor(String(m));
},
);
</script>
<template>
<div class="max-w-6xl mx-auto space-y-6">
<div class="flex items-center justify-between">
<div>
<RouterLink
:to="{ name: 'stats' }"
class="text-sm text-gray-500 hover:text-gray-900"
>
← К статистике
</RouterLink>
<h1 class="text-2xl font-bold mt-1">
Лиды менеджера
<span class="text-gray-500 font-normal">·</span>
{{ route.params.manager }}
<span class="text-gray-400 font-normal">({{ store.total }})</span>
</h1>
</div>
</div>
<LeadsTable />
</div>
</template>

View File

@ -0,0 +1,153 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getManagerStats, type ManagerStat, type ManagerTotals } from '@/api/stats';
const router = useRouter();
function openManager(name: string) {
router.push({ name: 'stats-manager', params: { manager: name } });
}
const managers = ref<ManagerStat[]>([]);
const totals = ref<ManagerTotals | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
async function load() {
loading.value = true;
error.value = null;
try {
const res = await getManagerStats();
managers.value = res.managers;
totals.value = res.totals;
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
onMounted(load);
function money(v: number) {
return v.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' ₸';
}
function score(v: number) {
return v.toFixed(1);
}
</script>
<template>
<div class="max-w-7xl mx-auto space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Статистика менеджеров</h1>
<button
type="button"
class="text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white hover:bg-gray-50"
:disabled="loading"
@click="load"
>
Обновить
</button>
</div>
<div v-if="loading" class="text-gray-500">Загрузка…</div>
<div v-else-if="error" class="text-red-600">{{ error }}</div>
<template v-else>
<div v-if="totals" class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="bg-white rounded-xl border border-gray-200 p-4">
<div class="text-xs text-gray-500 uppercase">Всего лидов</div>
<div class="text-2xl font-bold mt-1">{{ totals.total }}</div>
<div class="text-xs text-gray-500 mt-1">
+{{ totals.new_7d }} за 7д · +{{ totals.new_30d }} за 30д
</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-4">
<div class="text-xs text-gray-500 uppercase">Pipeline</div>
<div class="text-2xl font-bold mt-1">{{ money(totals.pipeline_amount) }}</div>
<div class="text-xs text-gray-500 mt-1">в работе: {{ totals.in_progress }}</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-4">
<div class="text-xs text-gray-500 uppercase">Выручка (won)</div>
<div class="text-2xl font-bold mt-1 text-green-700">
{{ money(totals.won_amount) }}
</div>
<div class="text-xs text-gray-500 mt-1">
won: {{ totals.won }} · lost: {{ totals.lost }}
</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-4">
<div class="text-xs text-gray-500 uppercase">Просрочено касаний</div>
<div
class="text-2xl font-bold mt-1"
:class="totals.overdue > 0 ? 'text-red-600' : 'text-gray-900'"
>
{{ totals.overdue }}
</div>
<div class="text-xs text-gray-500 mt-1">
🔥 {{ totals.hot }} · 🌡 {{ totals.warm }} · ❄ {{ totals.cold }}
</div>
</div>
</div>
<div
v-if="managers.length === 0"
class="bg-white rounded-xl border border-gray-200 p-12 text-center text-gray-500"
>
Лидов в системе ещё нет.
</div>
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-xs text-gray-500 uppercase">
<tr>
<th class="text-left px-4 py-3 font-medium">Менеджер</th>
<th class="text-right px-4 py-3 font-medium">Лиды</th>
<th class="text-right px-4 py-3 font-medium">🔥 / 🌡 / ❄</th>
<th class="text-right px-4 py-3 font-medium">Won / Lost</th>
<th class="text-right px-4 py-3 font-medium">Конверсия</th>
<th class="text-right px-4 py-3 font-medium">Avg score</th>
<th class="text-right px-4 py-3 font-medium">Pipeline</th>
<th class="text-right px-4 py-3 font-medium">Выручка</th>
<th class="text-right px-4 py-3 font-medium">Просрочено</th>
<th class="text-right px-4 py-3 font-medium">+7д</th>
</tr>
</thead>
<tbody>
<tr
v-for="m in managers"
:key="m.manager"
class="border-t border-gray-100 hover:bg-gray-50 cursor-pointer"
@click="openManager(m.manager)"
>
<td class="px-4 py-3 font-medium text-blue-700 hover:underline">
{{ m.manager }} →
</td>
<td class="px-4 py-3 text-right">{{ m.total }}</td>
<td class="px-4 py-3 text-right text-gray-600">
{{ m.hot }} / {{ m.warm }} / {{ m.cold }}
</td>
<td class="px-4 py-3 text-right">
<span class="text-green-700">{{ m.won }}</span>
/
<span class="text-red-600">{{ m.lost }}</span>
</td>
<td class="px-4 py-3 text-right">{{ m.conversion_rate }}%</td>
<td class="px-4 py-3 text-right">{{ score(m.avg_score) }}</td>
<td class="px-4 py-3 text-right text-gray-700">{{ money(m.pipeline_amount) }}</td>
<td class="px-4 py-3 text-right text-green-700">{{ money(m.won_amount) }}</td>
<td
class="px-4 py-3 text-right"
:class="m.overdue > 0 ? 'text-red-600 font-medium' : 'text-gray-500'"
>
{{ m.overdue }}
</td>
<td class="px-4 py-3 text-right text-gray-600">{{ m.new_7d }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>