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 { AppError } from './errors.js';
|
||||||
import type { JwtUser } from './types.js';
|
import type { JwtUser } from './types.js';
|
||||||
|
|
||||||
function parseAuthUsers(raw: string): Map<string, string> {
|
interface AuthUserEntry {
|
||||||
const map = new Map<string, string>();
|
hash: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAuthUsers(raw: string): Map<string, AuthUserEntry> {
|
||||||
|
const map = new Map<string, AuthUserEntry>();
|
||||||
for (const entry of raw.split(',')) {
|
for (const entry of raw.split(',')) {
|
||||||
const trimmed = entry.trim();
|
const trimmed = entry.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
const idx = trimmed.indexOf(':');
|
const parts = trimmed.split(':');
|
||||||
if (idx === -1) continue;
|
if (parts.length < 2) continue;
|
||||||
const username = trimmed.slice(0, idx);
|
const username = parts[0];
|
||||||
const hash = trimmed.slice(idx + 1);
|
let hash: string;
|
||||||
if (username && hash) map.set(username, hash);
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
@ -24,11 +37,11 @@ export async function verifyCredentials(
|
|||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<JwtUser | null> {
|
): Promise<JwtUser | null> {
|
||||||
const hash = users.get(username);
|
const entry = users.get(username);
|
||||||
if (!hash) return null;
|
if (!entry) return null;
|
||||||
const ok = await bcrypt.compare(password, hash);
|
const ok = await bcrypt.compare(password, entry.hash);
|
||||||
if (!ok) return null;
|
if (!ok) return null;
|
||||||
return { sub: username, role: username };
|
return { sub: username, role: entry.role };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAuth(
|
export async function requireAuth(
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { query } from '../db.js';
|
import { query } from '../db.js';
|
||||||
import { calculateScore } from '../scoring.js';
|
import { calculateScore } from '../scoring.js';
|
||||||
import { AppError } from '../errors.js';
|
import { AppError } from '../errors.js';
|
||||||
import { requireAuth } from '../auth.js';
|
import { requireAuth, requireRole } from '../auth.js';
|
||||||
import {
|
import {
|
||||||
DEAL_STAGES,
|
DEAL_STAGES,
|
||||||
type DealStage,
|
type DealStage,
|
||||||
@ -168,12 +168,22 @@ export async function leadRoutes(app: FastifyInstance) {
|
|||||||
sort?: string;
|
sort?: string;
|
||||||
limit?: string;
|
limit?: string;
|
||||||
offset?: string;
|
offset?: string;
|
||||||
|
created_by?: string;
|
||||||
};
|
};
|
||||||
}>('/leads', async (req) => {
|
}>('/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 where: string[] = [];
|
||||||
const params: unknown[] = [];
|
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 (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');
|
||||||
@ -194,7 +204,17 @@ export async function leadRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
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 lim = Math.min(Math.max(Number(limit ?? 50), 1), 500);
|
||||||
const off = Math.max(Number(offset ?? 0), 0);
|
const off = Math.max(Number(offset ?? 0), 0);
|
||||||
|
|
||||||
@ -436,6 +456,8 @@ export async function leadRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// DELETE
|
// DELETE
|
||||||
app.delete<{ Params: { id: string } }>('/leads/:id', async (req, reply) => {
|
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]);
|
const { rowCount } = await query(`DELETE FROM leads WHERE id = $1`, [req.params.id]);
|
||||||
if (!rowCount) throw AppError.notFound('Lead not found');
|
if (!rowCount) throw AppError.notFound('Lead not found');
|
||||||
return reply.code(204).send();
|
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 { authRoutes } from './routes/auth.js';
|
||||||
import { leadRoutes } from './routes/leads.js';
|
import { leadRoutes } from './routes/leads.js';
|
||||||
import { healthRoutes } from './routes/health.js';
|
import { healthRoutes } from './routes/health.js';
|
||||||
|
import { statsRoutes } from './routes/stats.js';
|
||||||
|
|
||||||
async function buildApp() {
|
async function buildApp() {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@ -36,6 +37,7 @@ async function buildApp() {
|
|||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(authRoutes);
|
await app.register(authRoutes);
|
||||||
await app.register(leadRoutes);
|
await app.register(leadRoutes);
|
||||||
|
await app.register(statsRoutes);
|
||||||
|
|
||||||
return app;
|
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>
|
||||||
|
<RouterLink
|
||||||
|
v-if="auth.isRop"
|
||||||
|
:to="{ name: 'stats' }"
|
||||||
|
class="text-sm text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Статистика
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="text-sm text-gray-500">
|
<span class="text-sm text-gray-500">
|
||||||
|
|||||||
@ -91,13 +91,35 @@ export interface HistoryEntry {
|
|||||||
changed_by: string;
|
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 {
|
export interface LeadListQuery {
|
||||||
status?: LeadStatus | 'all';
|
status?: LeadStatus | 'all';
|
||||||
lead_type?: LeadType | 'all';
|
lead_type?: LeadType | 'all';
|
||||||
deal_stage?: DealStage | 'all';
|
deal_stage?: DealStage | 'all';
|
||||||
sort?: 'score_desc' | 'score_asc';
|
sort?: LeadSort;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
created_by?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateLeadBody {
|
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'),
|
component: () => import('@/views/LeadCardView.vue'),
|
||||||
props: true,
|
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) {
|
if (to.name === 'login' && auth.isAuthenticated) {
|
||||||
return { name: 'leads' };
|
return { name: 'leads' };
|
||||||
}
|
}
|
||||||
|
if (to.meta.ropOnly && !auth.isRop) {
|
||||||
|
return { name: 'leads' };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
listLeads,
|
listLeads,
|
||||||
type DealStage,
|
type DealStage,
|
||||||
type LeadListItem,
|
type LeadListItem,
|
||||||
|
type LeadSort,
|
||||||
type LeadStatus,
|
type LeadStatus,
|
||||||
type LeadType,
|
type LeadType,
|
||||||
} from '@/api/leads';
|
} from '@/api/leads';
|
||||||
@ -14,6 +15,8 @@ interface State {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
sort: LeadSort;
|
||||||
|
createdBy: string | null;
|
||||||
filters: {
|
filters: {
|
||||||
status: LeadStatus | 'all';
|
status: LeadStatus | 'all';
|
||||||
lead_type: LeadType | 'all';
|
lead_type: LeadType | 'all';
|
||||||
@ -21,20 +24,29 @@ interface State {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLeadsStore = defineStore('leads', {
|
const INITIAL_STATE = (): State => ({
|
||||||
state: (): State => ({
|
|
||||||
items: [],
|
items: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
|
sort: 'score_desc',
|
||||||
|
createdBy: null,
|
||||||
filters: { status: 'all', lead_type: 'all', deal_stage: 'all' },
|
filters: { status: 'all', lead_type: 'all', deal_stage: 'all' },
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
export const useLeadsStore = defineStore('leads', {
|
||||||
|
state: INITIAL_STATE,
|
||||||
getters: {
|
getters: {
|
||||||
totalPages: (s): number => Math.max(1, Math.ceil(s.total / s.pageSize)),
|
totalPages: (s): number => Math.max(1, Math.ceil(s.total / s.pageSize)),
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
reset(createdBy: string | null = null) {
|
||||||
|
const fresh = INITIAL_STATE();
|
||||||
|
Object.assign(this, fresh);
|
||||||
|
this.createdBy = createdBy;
|
||||||
|
},
|
||||||
async load() {
|
async load() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
@ -43,9 +55,10 @@ export const useLeadsStore = defineStore('leads', {
|
|||||||
status: this.filters.status,
|
status: this.filters.status,
|
||||||
lead_type: this.filters.lead_type,
|
lead_type: this.filters.lead_type,
|
||||||
deal_stage: this.filters.deal_stage,
|
deal_stage: this.filters.deal_stage,
|
||||||
sort: 'score_desc',
|
sort: this.sort,
|
||||||
limit: this.pageSize,
|
limit: this.pageSize,
|
||||||
offset: (this.page - 1) * this.pageSize,
|
offset: (this.page - 1) * this.pageSize,
|
||||||
|
created_by: this.createdBy ?? undefined,
|
||||||
});
|
});
|
||||||
this.items = res.data;
|
this.items = res.data;
|
||||||
this.total = res.total;
|
this.total = res.total;
|
||||||
@ -70,6 +83,11 @@ export const useLeadsStore = defineStore('leads', {
|
|||||||
this.page = 1;
|
this.page = 1;
|
||||||
return this.load();
|
return this.load();
|
||||||
},
|
},
|
||||||
|
setSort(sort: LeadSort) {
|
||||||
|
this.sort = sort;
|
||||||
|
this.page = 1;
|
||||||
|
return this.load();
|
||||||
|
},
|
||||||
setStatus(status: LeadStatus | 'all') {
|
setStatus(status: LeadStatus | 'all') {
|
||||||
this.filters.status = status;
|
this.filters.status = status;
|
||||||
this.page = 1;
|
this.page = 1;
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { onMounted, ref, computed, reactive, watch } from 'vue';
|
import { onMounted, ref, computed, reactive, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import ScoreBadge from '@/components/ScoreBadge.vue';
|
import ScoreBadge from '@/components/ScoreBadge.vue';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import {
|
import {
|
||||||
deleteLead,
|
deleteLead,
|
||||||
getLead,
|
getLead,
|
||||||
@ -16,6 +17,7 @@ import {
|
|||||||
import { ApiRequestError } from '@/api/client';
|
import { ApiRequestError } from '@/api/client';
|
||||||
|
|
||||||
const props = defineProps<{ id: string }>();
|
const props = defineProps<{ id: string }>();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
const lead = ref<Lead | null>(null);
|
const lead = ref<Lead | null>(null);
|
||||||
const history = ref<HistoryEntry[]>([]);
|
const history = ref<HistoryEntry[]>([]);
|
||||||
@ -343,7 +345,7 @@ onMounted(load);
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div v-if="auth.isRop" class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-red-600 hover:text-red-800 text-sm"
|
class="text-red-600 hover:text-red-800 text-sm"
|
||||||
|
|||||||
@ -2,45 +2,14 @@
|
|||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import { RouterLink } from 'vue-router';
|
import { RouterLink } from 'vue-router';
|
||||||
import { useLeadsStore } from '@/stores/leads';
|
import { useLeadsStore } from '@/stores/leads';
|
||||||
import ScoreBadge from '@/components/ScoreBadge.vue';
|
import LeadsTable from '@/components/LeadsTable.vue';
|
||||||
import FilterBar from '@/components/FilterBar.vue';
|
|
||||||
import { DEAL_STAGE_LABELS, deleteLead, type DealStage } from '@/api/leads';
|
|
||||||
|
|
||||||
const store = useLeadsStore();
|
const store = useLeadsStore();
|
||||||
|
|
||||||
onMounted(() => store.load());
|
onMounted(async () => {
|
||||||
|
store.reset();
|
||||||
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();
|
await store.load();
|
||||||
} catch (e) {
|
});
|
||||||
alert((e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -54,121 +23,6 @@ async function removeLead(id: string, name: string) {
|
|||||||
+ Добавить лида
|
+ Добавить лида
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
<LeadsTable />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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