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:
parent
2d2b8784ec
commit
f38f61b0f6
3885
leadscore/backend/package-lock.json
generated
Normal file
3885
leadscore/backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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(
|
||||
|
||||
@ -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();
|
||||
|
||||
111
leadscore/backend/src/routes/stats.ts
Normal file
111
leadscore/backend/src/routes/stats.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@ -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
4371
leadscore/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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">
|
||||
|
||||
@ -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 {
|
||||
|
||||
40
leadscore/frontend/src/api/stats.ts
Normal file
40
leadscore/frontend/src/api/stats.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
196
leadscore/frontend/src/components/LeadsTable.vue
Normal file
196
leadscore/frontend/src/components/LeadsTable.vue
Normal 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>
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
};
|
||||
}
|
||||
|
||||
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: (): State => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
filters: { status: 'all', lead_type: 'all', deal_stage: 'all' },
|
||||
}),
|
||||
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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
await store.load();
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
store.reset();
|
||||
await store.load();
|
||||
});
|
||||
</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>
|
||||
|
||||
45
leadscore/frontend/src/views/ManagerLeadsView.vue
Normal file
45
leadscore/frontend/src/views/ManagerLeadsView.vue
Normal 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>
|
||||
153
leadscore/frontend/src/views/StatsView.vue
Normal file
153
leadscore/frontend/src/views/StatsView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user