Refresh UI with minimalist design system
- style.css: global component classes (btn-primary/secondary/ghost/danger, card, input-base, label, table-base, badge) and Inter font - App.vue: sticky blurred header, brand logo mark, active nav links, user avatar with initials - LoginView: centered card with brand mark, polished error display - ScoreBadge: compact pill with colored dot and tabular numerals - FilterBar: tighter pill buttons with uppercase group labels - LeadsTable: card-wrapped filters, table-base styling, empty state with icon, compact pagination - LeadsListView / ManagerLeadsView: typography hierarchy, subtitles - StatsView: 4 KPI cards with large tabular numbers, compact money formatter, manager rows with avatar circles - NewLeadView: card-pad layout, uppercase section labels, btn helpers, styled error block - LeadCardView: card-pad sections, sky/amber/emerald stage colors, refined recommendation block - QuestionButton: tighter label, subtle shadow on active option - index.html: preload Inter from Google Fonts Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
68cf004b6b
commit
cc42c6c29e
@ -3,6 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
/>
|
||||
<title>Lead Scoring</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -10,53 +10,84 @@ function logout() {
|
||||
auth.logout();
|
||||
router.push({ name: 'login' });
|
||||
}
|
||||
|
||||
function userInitials(name: string | undefined): string {
|
||||
if (!name) return '?';
|
||||
return name
|
||||
.split(/[\s._-]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase() ?? '')
|
||||
.join('') || name[0].toUpperCase();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-full bg-gray-50 text-gray-900">
|
||||
<div class="min-h-full">
|
||||
<header
|
||||
v-if="auth.isAuthenticated"
|
||||
class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between"
|
||||
class="bg-white/80 backdrop-blur border-b border-gray-200/80 sticky top-0 z-30"
|
||||
>
|
||||
<div class="flex items-center gap-6">
|
||||
<RouterLink :to="{ name: 'leads' }" class="font-bold text-lg">
|
||||
Lead Scoring
|
||||
<div class="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-7">
|
||||
<RouterLink :to="{ name: 'leads' }" class="flex items-center gap-2 group">
|
||||
<div
|
||||
class="w-7 h-7 rounded-lg bg-gray-900 text-white flex items-center justify-center font-bold text-sm group-hover:bg-gray-800 transition"
|
||||
>
|
||||
L
|
||||
</div>
|
||||
<span class="font-semibold tracking-tight text-[15px]">Lead Scoring</span>
|
||||
</RouterLink>
|
||||
<nav class="flex items-center gap-5">
|
||||
<RouterLink
|
||||
:to="{ name: 'leads' }"
|
||||
class="text-sm text-gray-600 hover:text-gray-900"
|
||||
class="nav-link"
|
||||
active-class="nav-link-active"
|
||||
>
|
||||
Лиды
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="{ name: 'new-lead' }"
|
||||
class="text-sm text-gray-600 hover:text-gray-900"
|
||||
class="nav-link"
|
||||
active-class="nav-link-active"
|
||||
>
|
||||
+ Новый лид
|
||||
+ Новый
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="auth.isRop"
|
||||
:to="{ name: 'stats' }"
|
||||
class="text-sm text-gray-600 hover:text-gray-900"
|
||||
class="nav-link"
|
||||
active-class="nav-link-active"
|
||||
>
|
||||
Статистика
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<BellMenu v-if="auth.user?.role === 'manager'" />
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ auth.user?.username }} ({{ auth.user?.role }})
|
||||
</span>
|
||||
<div class="flex items-center gap-2 pl-3 border-l border-gray-200">
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-gray-100 text-gray-700 flex items-center justify-center text-xs font-semibold"
|
||||
:title="auth.user?.username"
|
||||
>
|
||||
{{ userInitials(auth.user?.username) }}
|
||||
</div>
|
||||
<div class="text-xs leading-tight hidden sm:block">
|
||||
<div class="font-medium text-gray-900">{{ auth.user?.username }}</div>
|
||||
<div class="text-gray-500 capitalize">{{ auth.user?.role }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-gray-600 hover:text-gray-900"
|
||||
class="ml-2 text-xs text-gray-500 hover:text-gray-900 transition"
|
||||
@click="logout"
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="p-6">
|
||||
<main class="max-w-7xl mx-auto px-6 py-8">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -12,9 +12,9 @@ const store = useLeadsStore();
|
||||
|
||||
const statuses: { value: LeadStatus | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: 'Все' },
|
||||
{ value: 'hot', label: '🟢 Горячие' },
|
||||
{ value: 'warm', label: '🟡 Тёплые' },
|
||||
{ value: 'cold', label: '🔴 Холодные' },
|
||||
{ value: 'hot', label: 'Горячие' },
|
||||
{ value: 'warm', label: 'Тёплые' },
|
||||
{ value: 'cold', label: 'Холодные' },
|
||||
];
|
||||
|
||||
const types: { value: LeadType | 'all'; label: string }[] = [
|
||||
@ -27,56 +27,49 @@ const stages: { value: DealStage | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: 'Все этапы' },
|
||||
...DEAL_STAGES.map((s) => ({ value: s, label: DEAL_STAGE_LABELS[s] })),
|
||||
];
|
||||
|
||||
const pill = (active: boolean) =>
|
||||
active
|
||||
? 'bg-gray-900 text-white border-gray-900'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300 hover:text-gray-900';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm text-gray-500">Статус:</span>
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-3 items-center">
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<span class="text-xs uppercase tracking-wider text-gray-400 mr-1">Статус</span>
|
||||
<button
|
||||
v-for="s in statuses"
|
||||
:key="s.value"
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-full border text-sm transition"
|
||||
:class="
|
||||
store.filters.status === s.value
|
||||
? 'bg-gray-900 text-white border-gray-900'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
"
|
||||
class="px-2.5 py-1 rounded-full border text-xs font-medium transition"
|
||||
:class="pill(store.filters.status === s.value)"
|
||||
@click="store.setStatus(s.value)"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm text-gray-500">Тип:</span>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<span class="text-xs uppercase tracking-wider text-gray-400 mr-1">Тип</span>
|
||||
<button
|
||||
v-for="t in types"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-full border text-sm transition"
|
||||
:class="
|
||||
store.filters.lead_type === t.value
|
||||
? 'bg-gray-900 text-white border-gray-900'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
"
|
||||
class="px-2.5 py-1 rounded-full border text-xs font-medium transition"
|
||||
:class="pill(store.filters.lead_type === t.value)"
|
||||
@click="store.setLeadType(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm text-gray-500">Этап:</span>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<span class="text-xs uppercase tracking-wider text-gray-400 mr-1">Этап</span>
|
||||
<button
|
||||
v-for="s in stages"
|
||||
:key="s.value"
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-full border text-sm transition"
|
||||
:class="
|
||||
store.filters.deal_stage === s.value
|
||||
? 'bg-gray-900 text-white border-gray-900'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
"
|
||||
class="px-2.5 py-1 rounded-full border text-xs font-medium transition"
|
||||
:class="pill(store.filters.deal_stage === s.value)"
|
||||
@click="store.setDealStage(s.value)"
|
||||
>
|
||||
{{ s.label }}
|
||||
|
||||
@ -43,10 +43,10 @@ function formatMoney(v: number | null) {
|
||||
|
||||
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',
|
||||
contacted: 'bg-sky-100 text-sky-700',
|
||||
proposal: 'bg-amber-100 text-amber-700',
|
||||
won: 'bg-emerald-100 text-emerald-700',
|
||||
lost: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
async function removeLead(id: string, name: string) {
|
||||
@ -62,14 +62,16 @@ async function removeLead(id: string, name: string) {
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div
|
||||
class="card px-4 py-3 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>
|
||||
<label class="text-xs 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"
|
||||
class="input-base !py-1 !px-2 w-auto text-xs"
|
||||
>
|
||||
<option v-for="s in sortOptions" :key="s" :value="s">
|
||||
{{ LEAD_SORT_LABELS[s] }}
|
||||
@ -78,66 +80,76 @@ async function removeLead(id: string, name: string) {
|
||||
</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-if="store.loading" class="card p-12 text-center text-gray-400">Загрузка…</div>
|
||||
<div
|
||||
v-else-if="store.error"
|
||||
class="card p-6 text-sm text-red-600 bg-red-50/50 border-red-100"
|
||||
>
|
||||
{{ 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"
|
||||
class="card p-16 text-center"
|
||||
>
|
||||
Лидов не найдено.
|
||||
<div class="mx-auto w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-3 text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"/></svg>
|
||||
</div>
|
||||
<div 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">
|
||||
<div class="text-sm font-medium text-gray-900">Лидов не найдено</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Попробуйте сбросить фильтры или добавить нового.</div>
|
||||
</div>
|
||||
<div v-else class="card overflow-hidden">
|
||||
<table class="table-base">
|
||||
<thead>
|
||||
<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>
|
||||
<th>Score</th>
|
||||
<th>Контакт</th>
|
||||
<th>Компания</th>
|
||||
<th>Этап</th>
|
||||
<th class="text-right">Сумма</th>
|
||||
<th>Касание</th>
|
||||
<th>Тип</th>
|
||||
<th>Автор</th>
|
||||
<th></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"
|
||||
class="cursor-pointer"
|
||||
@click="router.push({ name: 'lead-card', params: { id: lead.id } })"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<ScoreBadge :score="lead.score" :status="lead.status" />
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div>{{ lead.contact_name }}</div>
|
||||
<div class="font-medium text-gray-900">{{ lead.contact_name }}</div>
|
||||
<div v-if="lead.position" class="text-xs text-gray-500">{{ lead.position }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div>{{ lead.company_name }}</div>
|
||||
<div class="text-gray-900">{{ lead.company_name }}</div>
|
||||
<div v-if="lead.city" class="text-xs text-gray-500">{{ lead.city }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-1 rounded-full"
|
||||
:class="stageClass[lead.deal_stage]"
|
||||
>
|
||||
<span class="badge" :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 class="px-4 py-3 text-right tabular-nums text-gray-700">
|
||||
{{ formatMoney(lead.expected_amount) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600">{{ lead.created_by }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 tabular-nums">
|
||||
{{ formatDate(lead.next_action_date) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs uppercase tracking-wider">
|
||||
{{ lead.lead_type === 'inbound' ? 'In' : 'Out' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 text-xs">{{ lead.created_by }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
v-if="auth.isRop"
|
||||
type="button"
|
||||
class="text-red-600 hover:text-red-800 text-sm"
|
||||
class="text-xs text-red-600 hover:text-red-800 transition opacity-0 group-hover:opacity-100"
|
||||
title="Удалить"
|
||||
@click.stop="removeLead(lead.id, lead.contact_name)"
|
||||
>
|
||||
@ -151,40 +163,40 @@ async function removeLead(id: string, name: string) {
|
||||
|
||||
<div
|
||||
v-if="store.items.length > 0"
|
||||
class="flex items-center justify-between text-sm text-gray-700"
|
||||
class="flex items-center justify-between text-sm text-gray-600 px-1"
|
||||
>
|
||||
<div>
|
||||
<label class="mr-2">На странице:</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-xs text-gray-500">На странице</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"
|
||||
class="input-base !py-1 !px-2 w-auto text-xs"
|
||||
>
|
||||
<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">
|
||||
<span class="text-xs text-gray-500 tabular-nums">
|
||||
{{ (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">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-md border border-gray-300 bg-white disabled:opacity-40"
|
||||
class="btn-secondary !py-1 !px-3 text-xs"
|
||||
:disabled="store.page <= 1 || store.loading"
|
||||
@click="store.setPage(store.page - 1)"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span class="px-2">{{ store.page }} / {{ store.totalPages }}</span>
|
||||
<span class="px-3 text-xs tabular-nums">{{ store.page }} / {{ store.totalPages }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-md border border-gray-300 bg-white disabled:opacity-40"
|
||||
class="btn-secondary !py-1 !px-3 text-xs"
|
||||
:disabled="store.page >= store.totalPages || store.loading"
|
||||
@click="store.setPage(store.page + 1)"
|
||||
>
|
||||
|
||||
@ -19,17 +19,19 @@ function select(v: T) {
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700">{{ label }}</label>
|
||||
<label class="block text-xs font-medium text-gray-600 uppercase tracking-wider">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="o in options"
|
||||
:key="String(o.value)"
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg border text-sm transition"
|
||||
class="px-3.5 py-1.5 rounded-lg border text-sm transition"
|
||||
:class="
|
||||
props.modelValue === o.value
|
||||
? 'bg-gray-900 text-white border-gray-900'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
? 'bg-gray-900 text-white border-gray-900 shadow-sm'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300 hover:text-gray-900'
|
||||
"
|
||||
@click="select(o.value)"
|
||||
>
|
||||
|
||||
@ -4,35 +4,46 @@ import type { LeadStatus } from '@/api/leads';
|
||||
|
||||
const props = defineProps<{ score: number; status: LeadStatus }>();
|
||||
|
||||
const styles = computed(() => {
|
||||
const dotColor = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'cold':
|
||||
return 'bg-red-100 text-red-700 border-red-300';
|
||||
return 'bg-sky-400';
|
||||
case 'warm':
|
||||
return 'bg-yellow-100 text-yellow-700 border-yellow-300';
|
||||
return 'bg-amber-400';
|
||||
case 'hot':
|
||||
return 'bg-green-100 text-green-700 border-green-300';
|
||||
return 'bg-emerald-500';
|
||||
}
|
||||
});
|
||||
|
||||
const icon = computed(() => {
|
||||
const textColor = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'cold':
|
||||
return '🔴';
|
||||
return 'text-sky-700';
|
||||
case 'warm':
|
||||
return '🟡';
|
||||
return 'text-amber-700';
|
||||
case 'hot':
|
||||
return '🟢';
|
||||
return 'text-emerald-700';
|
||||
}
|
||||
});
|
||||
|
||||
const bgColor = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'cold':
|
||||
return 'bg-sky-50';
|
||||
case 'warm':
|
||||
return 'bg-amber-50';
|
||||
case 'hot':
|
||||
return 'bg-emerald-50';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm font-semibold border"
|
||||
:class="styles"
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-semibold tabular-nums"
|
||||
:class="[bgColor, textColor]"
|
||||
>
|
||||
<span>{{ icon }}</span>
|
||||
<span>{{ score }}</span>
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="dotColor"></span>
|
||||
{{ score }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@ -2,9 +2,87 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body, #app {
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background-color: #fafafa;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.input-base {
|
||||
@apply w-full border border-gray-200 rounded-lg px-3 py-2 bg-white text-sm transition
|
||||
focus:outline-none focus:border-gray-900 focus:ring-2 focus:ring-gray-900/10;
|
||||
}
|
||||
.input-error {
|
||||
@apply border-red-400 focus:border-red-500 focus:ring-red-500/10;
|
||||
}
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium
|
||||
transition disabled:opacity-50 disabled:cursor-not-allowed select-none
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-gray-900/20;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-gray-900 text-white hover:bg-gray-800 active:bg-black;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply btn bg-white text-gray-900 border border-gray-200 hover:bg-gray-50 hover:border-gray-300;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply btn bg-transparent text-gray-600 hover:text-gray-900 hover:bg-gray-100;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn bg-white text-red-600 border border-red-200 hover:bg-red-50;
|
||||
}
|
||||
.card {
|
||||
@apply bg-white rounded-2xl border border-gray-200/80;
|
||||
}
|
||||
.card-pad {
|
||||
@apply card p-6;
|
||||
}
|
||||
.label {
|
||||
@apply block text-xs font-medium text-gray-600 mb-1.5;
|
||||
}
|
||||
.label-req::after {
|
||||
content: ' *';
|
||||
@apply text-red-500;
|
||||
}
|
||||
.nav-link {
|
||||
@apply text-sm text-gray-500 hover:text-gray-900 transition;
|
||||
}
|
||||
.nav-link-active {
|
||||
@apply text-gray-900 font-medium;
|
||||
}
|
||||
.table-base {
|
||||
@apply w-full text-sm;
|
||||
}
|
||||
.table-base thead {
|
||||
@apply bg-gray-50/60 text-[11px] text-gray-500 uppercase tracking-wider;
|
||||
}
|
||||
.table-base th {
|
||||
@apply text-left px-4 py-3 font-medium;
|
||||
}
|
||||
.table-base tbody tr {
|
||||
@apply border-t border-gray-100 transition;
|
||||
}
|
||||
.table-base tbody tr:hover {
|
||||
@apply bg-gray-50/70;
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide native number input spinners (used by date inputs etc.) */
|
||||
input[type='date']::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@ -52,10 +52,10 @@ const stageBadgeClass = computed(() => {
|
||||
if (!lead.value) return '';
|
||||
const map: 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',
|
||||
contacted: 'bg-sky-100 text-sky-700',
|
||||
proposal: 'bg-amber-100 text-amber-700',
|
||||
won: 'bg-emerald-100 text-emerald-700',
|
||||
lost: 'bg-red-100 text-red-700',
|
||||
};
|
||||
return map[lead.value.deal_stage];
|
||||
});
|
||||
@ -180,7 +180,7 @@ onMounted(load);
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-gray-600 hover:text-gray-900"
|
||||
class="text-xs text-gray-500 hover:text-gray-900 transition inline-flex items-center gap-1"
|
||||
@click="$router.push({ name: 'leads' })"
|
||||
>
|
||||
← К списку
|
||||
@ -190,7 +190,7 @@ onMounted(load);
|
||||
<div v-else-if="error" class="text-red-600">{{ error }}</div>
|
||||
|
||||
<template v-else-if="lead">
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<div class="card-pad space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-bold truncate">{{ lead.contact_name }}</h1>
|
||||
@ -231,26 +231,26 @@ onMounted(load);
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg p-4"
|
||||
class="rounded-xl p-4 border"
|
||||
:class="{
|
||||
'bg-red-50 text-red-900': lead.status === 'cold',
|
||||
'bg-yellow-50 text-yellow-900': lead.status === 'warm',
|
||||
'bg-green-50 text-green-900': lead.status === 'hot',
|
||||
'bg-sky-50/60 text-sky-900 border-sky-100': lead.status === 'cold',
|
||||
'bg-amber-50/60 text-amber-900 border-amber-100': lead.status === 'warm',
|
||||
'bg-emerald-50/60 text-emerald-900 border-emerald-100': lead.status === 'hot',
|
||||
}"
|
||||
>
|
||||
<p class="font-medium">Рекомендация</p>
|
||||
<p>{{ recommendation }}</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider opacity-70">Рекомендация</p>
|
||||
<p class="mt-1 text-sm">{{ recommendation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h2 class="font-semibold">Pipeline</h2>
|
||||
<div class="card-pad space-y-4">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Pipeline</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Этап воронки</label>
|
||||
<select
|
||||
v-model="pipelineForm.deal_stage"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 bg-white"
|
||||
class="input-base"
|
||||
>
|
||||
<option v-for="s in DEAL_STAGES" :key="s" :value="s">
|
||||
{{ DEAL_STAGE_LABELS[s] }}
|
||||
@ -266,7 +266,7 @@ onMounted(load);
|
||||
<input
|
||||
v-model="pipelineForm.next_action_date"
|
||||
type="date"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
class="input-base"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
@ -274,14 +274,14 @@ onMounted(load);
|
||||
<textarea
|
||||
v-model="pipelineForm.comment"
|
||||
rows="3"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
class="input-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800 disabled:opacity-50"
|
||||
class="btn-primary"
|
||||
:disabled="saving"
|
||||
@click="savePipeline"
|
||||
>
|
||||
@ -299,8 +299,8 @@ onMounted(load);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3">
|
||||
<h2 class="font-semibold">Детализация баллов</h2>
|
||||
<div class="card-pad space-y-3">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Детализация баллов</h2>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
<li
|
||||
v-for="(pts, key) in lead.breakdown"
|
||||
@ -320,8 +320,8 @@ onMounted(load);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3">
|
||||
<h2 class="font-semibold">История изменений score</h2>
|
||||
<div class="card-pad space-y-3">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">История изменений score</h2>
|
||||
<div v-if="history.length === 0" class="text-sm text-gray-500">
|
||||
История пуста
|
||||
</div>
|
||||
@ -342,7 +342,7 @@ onMounted(load);
|
||||
<div v-if="auth.isRop" class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 hover:text-red-800 text-sm"
|
||||
class="btn-danger !py-1.5 text-xs"
|
||||
@click="remove"
|
||||
>
|
||||
Удалить лида
|
||||
|
||||
@ -13,13 +13,15 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Лиды ({{ store.total }})</h1>
|
||||
<RouterLink
|
||||
:to="{ name: 'new-lead' }"
|
||||
class="bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Лиды</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Всего {{ store.total }} {{ store.total === 1 ? 'лид' : 'лидов' }}
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'new-lead' }" class="btn-primary">
|
||||
+ Добавить лида
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
@ -33,37 +33,55 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-sm mx-auto mt-24 bg-white rounded-xl shadow p-8">
|
||||
<h1 class="text-2xl font-bold mb-6">Вход</h1>
|
||||
<div class="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="flex items-center justify-center gap-2 mb-8">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-gray-900 text-white flex items-center justify-center font-bold text-lg"
|
||||
>
|
||||
L
|
||||
</div>
|
||||
<span class="text-xl font-semibold tracking-tight">Lead Scoring</span>
|
||||
</div>
|
||||
|
||||
<div class="card-pad shadow-sm">
|
||||
<h1 class="text-lg font-semibold mb-1">Вход</h1>
|
||||
<p class="text-sm text-gray-500 mb-6">Войдите в свою учётную запись</p>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="submit">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Логин</label>
|
||||
<label class="label">Логин</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-900"
|
||||
class="input-base"
|
||||
placeholder="manager1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Пароль</label>
|
||||
<label class="label">Пароль</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-900"
|
||||
class="input-base"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full bg-gray-900 text-white rounded-lg py-2 font-medium hover:bg-gray-800 disabled:opacity-50"
|
||||
<p
|
||||
v-if="error"
|
||||
class="text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-3 py-2"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<button type="submit" :disabled="loading" class="btn-primary w-full">
|
||||
{{ loading ? 'Вход…' : 'Войти' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -23,21 +23,23 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<RouterLink
|
||||
:to="{ name: 'stats' }"
|
||||
class="text-sm text-gray-500 hover:text-gray-900"
|
||||
class="text-xs text-gray-500 hover:text-gray-900 transition inline-flex items-center gap-1"
|
||||
>
|
||||
← К статистике
|
||||
</RouterLink>
|
||||
<h1 class="text-2xl font-bold mt-1">
|
||||
Лиды менеджера
|
||||
<span class="text-gray-500 font-normal">·</span>
|
||||
<div class="flex items-end justify-between mt-2">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">
|
||||
{{ route.params.manager }}
|
||||
<span class="text-gray-400 font-normal">({{ store.total }})</span>
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Лиды менеджера · {{ store.total }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LeadsTable />
|
||||
|
||||
@ -201,11 +201,16 @@ async function submit() {
|
||||
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<h1 class="text-2xl font-bold">Новый лид</h1>
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Новый лид</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Заполните основные данные — скор посчитается автоматически.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="bg-white rounded-xl border border-gray-200 p-6 space-y-6" @submit.prevent="submit">
|
||||
<form class="card-pad space-y-8" @submit.prevent="submit">
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Контакт</h2>
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Контакт</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">
|
||||
@ -214,7 +219,7 @@ async function submit() {
|
||||
<input
|
||||
v-model="form.contact_name"
|
||||
type="text"
|
||||
class="w-full border rounded-lg px-3 py-2"
|
||||
class="input-base"
|
||||
:class="displayErrors.contact_name ? 'border-red-500' : 'border-gray-300'"
|
||||
/>
|
||||
<p v-if="displayErrors.contact_name" class="mt-1 text-xs text-red-600">
|
||||
@ -229,7 +234,7 @@ async function submit() {
|
||||
v-model="form.contact_phone"
|
||||
type="tel"
|
||||
placeholder="+7 ..."
|
||||
class="w-full border rounded-lg px-3 py-2"
|
||||
class="input-base"
|
||||
:class="displayErrors.contact_phone ? 'border-red-500' : 'border-gray-300'"
|
||||
/>
|
||||
<p v-if="displayErrors.contact_phone" class="mt-1 text-xs text-red-600">
|
||||
@ -244,7 +249,7 @@ async function submit() {
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="ivan@acme.com"
|
||||
class="w-full border rounded-lg px-3 py-2"
|
||||
class="input-base"
|
||||
:class="displayErrors.email ? 'border-red-500' : 'border-gray-300'"
|
||||
/>
|
||||
<p v-if="displayErrors.email" class="mt-1 text-xs text-red-600">
|
||||
@ -257,14 +262,14 @@ async function submit() {
|
||||
v-model="form.position"
|
||||
type="text"
|
||||
placeholder="CEO / Head of Sales / …"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
class="input-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Компания</h2>
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Компания</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium mb-1">
|
||||
@ -273,7 +278,7 @@ async function submit() {
|
||||
<input
|
||||
v-model="form.company_name"
|
||||
type="text"
|
||||
class="w-full border rounded-lg px-3 py-2"
|
||||
class="input-base"
|
||||
:class="displayErrors.company_name ? 'border-red-500' : 'border-gray-300'"
|
||||
/>
|
||||
<p v-if="displayErrors.company_name" class="mt-1 text-xs text-red-600">
|
||||
@ -286,7 +291,7 @@ async function submit() {
|
||||
v-model="form.website"
|
||||
type="url"
|
||||
placeholder="https://acme.com"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
class="input-base"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -297,7 +302,7 @@ async function submit() {
|
||||
v-model="form.industry"
|
||||
type="text"
|
||||
placeholder="SaaS / Retail / …"
|
||||
class="w-full border rounded-lg px-3 py-2"
|
||||
class="input-base"
|
||||
:class="displayErrors.industry ? 'border-red-500' : 'border-gray-300'"
|
||||
/>
|
||||
<p v-if="displayErrors.industry" class="mt-1 text-xs text-red-600">
|
||||
@ -312,7 +317,7 @@ async function submit() {
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
placeholder="Алматы"
|
||||
class="w-full border rounded-lg px-3 py-2"
|
||||
class="input-base"
|
||||
:class="displayErrors.city ? 'border-red-500' : 'border-gray-300'"
|
||||
/>
|
||||
<p v-if="displayErrors.city" class="mt-1 text-xs text-red-600">
|
||||
@ -323,7 +328,7 @@ async function submit() {
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">
|
||||
Скоринг <span class="text-red-600">*</span>
|
||||
</h2>
|
||||
<QuestionButton label="Тип лида" v-model="form.lead_type" :options="leadTypeOpts" />
|
||||
@ -382,13 +387,13 @@ async function submit() {
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Сделка</h2>
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Сделка</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Этап воронки</label>
|
||||
<select
|
||||
v-model="form.deal_stage"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 bg-white"
|
||||
class="input-base"
|
||||
>
|
||||
<option v-for="s in DEAL_STAGES" :key="s" :value="s">
|
||||
{{ DEAL_STAGE_LABELS[s] }}
|
||||
@ -404,7 +409,7 @@ async function submit() {
|
||||
<input
|
||||
v-model="form.next_action_date"
|
||||
type="date"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
class="input-base"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
@ -413,25 +418,30 @@ async function submit() {
|
||||
v-model="form.comment"
|
||||
rows="3"
|
||||
placeholder="О чём договорились, что нужно уточнить…"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
class="input-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
<p
|
||||
v-if="error"
|
||||
class="text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-3 py-2"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!canSubmit || loading"
|
||||
class="bg-gray-900 text-white rounded-lg px-5 py-2 font-medium hover:bg-gray-800 disabled:opacity-50"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ loading ? 'Сохраняем…' : 'Создать' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-600 hover:text-gray-900 px-5 py-2"
|
||||
class="btn-ghost"
|
||||
@click="$router.push({ name: 'leads' })"
|
||||
>
|
||||
Отмена
|
||||
|
||||
@ -31,7 +31,9 @@ async function load() {
|
||||
onMounted(load);
|
||||
|
||||
function money(v: number) {
|
||||
return v.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' ₸';
|
||||
if (v >= 1_000_000) return (v / 1_000_000).toFixed(1).replace('.0', '') + 'M ₸';
|
||||
if (v >= 1_000) return Math.round(v / 1_000) + 'K ₸';
|
||||
return v.toLocaleString('ru-RU') + ' ₸';
|
||||
}
|
||||
|
||||
function score(v: number) {
|
||||
@ -40,12 +42,15 @@ function score(v: number) {
|
||||
</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>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Статистика менеджеров</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Сводка по всей команде продаж</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white hover:bg-gray-50"
|
||||
class="btn-secondary"
|
||||
:disabled="loading"
|
||||
@click="load"
|
||||
>
|
||||
@ -53,97 +58,130 @@ function score(v: number) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-gray-500">Загрузка…</div>
|
||||
<div v-else-if="error" class="text-red-600">{{ error }}</div>
|
||||
<div v-if="loading" class="card p-12 text-center text-gray-400">Загрузка…</div>
|
||||
<div v-else-if="error" class="card p-6 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 v-if="totals" class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="card p-5">
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wider">Всего лидов</div>
|
||||
<div class="text-3xl font-semibold tracking-tight mt-2 tabular-nums">
|
||||
{{ totals.total }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
<span class="text-emerald-600 font-medium">+{{ totals.new_7d }}</span>
|
||||
за 7д ·
|
||||
<span class="text-emerald-600 font-medium">+{{ totals.new_30d }}</span>
|
||||
за 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 class="card p-5">
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wider">Pipeline</div>
|
||||
<div class="text-3xl font-semibold tracking-tight mt-2 tabular-nums">
|
||||
{{ money(totals.pipeline_amount) }}
|
||||
</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">
|
||||
<div class="text-xs text-gray-500 mt-2">в работе: {{ totals.in_progress }}</div>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wider">Выручка</div>
|
||||
<div class="text-3xl font-semibold tracking-tight mt-2 tabular-nums text-emerald-700">
|
||||
{{ money(totals.won_amount) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
won: {{ totals.won }} · lost: {{ totals.lost }}
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
<span class="text-emerald-600">won {{ totals.won }}</span>
|
||||
·
|
||||
<span class="text-red-500">lost {{ totals.lost }}</span>
|
||||
</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="card p-5">
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wider">Просрочено</div>
|
||||
<div
|
||||
class="text-2xl font-bold mt-1"
|
||||
class="text-3xl font-semibold tracking-tight mt-2 tabular-nums"
|
||||
: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 class="text-xs text-gray-500 mt-2 flex gap-3">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||
{{ totals.hot }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
|
||||
{{ totals.warm }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-sky-400"></span>
|
||||
{{ totals.cold }}
|
||||
</span>
|
||||
</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"
|
||||
class="card p-16 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">
|
||||
<div v-else class="card overflow-hidden">
|
||||
<table class="table-base">
|
||||
<thead>
|
||||
<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>
|
||||
<th>Менеджер</th>
|
||||
<th class="text-right">Лиды</th>
|
||||
<th class="text-right">🔥 / 🌡 / ❄</th>
|
||||
<th class="text-right">Won / Lost</th>
|
||||
<th class="text-right">Конв.</th>
|
||||
<th class="text-right">Avg score</th>
|
||||
<th class="text-right">Pipeline</th>
|
||||
<th class="text-right">Выручка</th>
|
||||
<th class="text-right">Просрочено</th>
|
||||
<th class="text-right">+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"
|
||||
class="cursor-pointer"
|
||||
@click="openManager(m.manager)"
|
||||
>
|
||||
<td class="px-4 py-3 font-medium text-blue-700 hover:underline">
|
||||
{{ m.manager }} →
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-gray-100 text-gray-700 flex items-center justify-center text-xs font-semibold"
|
||||
>
|
||||
{{ m.manager[0].toUpperCase() }}
|
||||
</div>
|
||||
<span class="font-medium text-gray-900">{{ m.manager }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">{{ m.total }}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-600">
|
||||
<td class="px-4 py-3 text-right tabular-nums">{{ m.total }}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-500 tabular-nums">
|
||||
{{ 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 class="px-4 py-3 text-right tabular-nums">
|
||||
<span class="text-emerald-700">{{ m.won }}</span>
|
||||
<span class="text-gray-300"> / </span>
|
||||
<span class="text-red-500">{{ m.lost }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums">{{ m.conversion_rate }}%</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums text-gray-700">
|
||||
{{ score(m.avg_score) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums text-gray-700">
|
||||
{{ money(m.pipeline_amount) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums text-emerald-700">
|
||||
{{ money(m.won_amount) }}
|
||||
</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'"
|
||||
class="px-4 py-3 text-right tabular-nums"
|
||||
:class="m.overdue > 0 ? 'text-red-600 font-medium' : 'text-gray-400'"
|
||||
>
|
||||
{{ m.overdue }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-gray-600">{{ m.new_7d }}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums text-gray-600">{{ m.new_7d }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user