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:
ask 2026-05-19 16:28:07 +05:00
parent 68cf004b6b
commit cc42c6c29e
13 changed files with 486 additions and 283 deletions

View File

@ -3,6 +3,12 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <title>Lead Scoring</title>
</head> </head>
<body> <body>

View File

@ -10,53 +10,84 @@ function logout() {
auth.logout(); auth.logout();
router.push({ name: 'login' }); 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> </script>
<template> <template>
<div class="min-h-full bg-gray-50 text-gray-900"> <div class="min-h-full">
<header <header
v-if="auth.isAuthenticated" 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"> <div class="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between">
<RouterLink :to="{ name: 'leads' }" class="font-bold text-lg"> <div class="flex items-center gap-7">
Lead Scoring <RouterLink :to="{ name: 'leads' }" class="flex items-center gap-2 group">
</RouterLink> <div
<RouterLink 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"
:to="{ name: 'leads' }" >
class="text-sm text-gray-600 hover:text-gray-900" L
> </div>
Лиды <span class="font-semibold tracking-tight text-[15px]">Lead Scoring</span>
</RouterLink> </RouterLink>
<RouterLink <nav class="flex items-center gap-5">
:to="{ name: 'new-lead' }" <RouterLink
class="text-sm text-gray-600 hover:text-gray-900" :to="{ name: 'leads' }"
> class="nav-link"
+ Новый лид active-class="nav-link-active"
</RouterLink> >
<RouterLink Лиды
v-if="auth.isRop" </RouterLink>
:to="{ name: 'stats' }" <RouterLink
class="text-sm text-gray-600 hover:text-gray-900" :to="{ name: 'new-lead' }"
> class="nav-link"
Статистика active-class="nav-link-active"
</RouterLink> >
</div> + Новый
<div class="flex items-center gap-4"> </RouterLink>
<BellMenu v-if="auth.user?.role === 'manager'" /> <RouterLink
<span class="text-sm text-gray-500"> v-if="auth.isRop"
{{ auth.user?.username }} ({{ auth.user?.role }}) :to="{ name: 'stats' }"
</span> class="nav-link"
<button active-class="nav-link-active"
type="button" >
class="text-sm text-gray-600 hover:text-gray-900" Статистика
@click="logout" </RouterLink>
> </nav>
Выйти </div>
</button> <div class="flex items-center gap-3">
<BellMenu v-if="auth.user?.role === 'manager'" />
<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="ml-2 text-xs text-gray-500 hover:text-gray-900 transition"
@click="logout"
>
Выйти
</button>
</div>
</div>
</div> </div>
</header> </header>
<main class="p-6"> <main class="max-w-7xl mx-auto px-6 py-8">
<RouterView /> <RouterView />
</main> </main>
</div> </div>

View File

@ -12,9 +12,9 @@ const store = useLeadsStore();
const statuses: { value: LeadStatus | 'all'; label: string }[] = [ const statuses: { value: LeadStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'Все' }, { value: 'all', label: 'Все' },
{ value: 'hot', label: '🟢 Горячие' }, { value: 'hot', label: 'Горячие' },
{ value: 'warm', label: '🟡 Тёплые' }, { value: 'warm', label: 'Тёплые' },
{ value: 'cold', label: '🔴 Холодные' }, { value: 'cold', label: 'Холодные' },
]; ];
const types: { value: LeadType | 'all'; label: string }[] = [ const types: { value: LeadType | 'all'; label: string }[] = [
@ -27,56 +27,49 @@ const stages: { value: DealStage | 'all'; label: string }[] = [
{ value: 'all', label: 'Все этапы' }, { value: 'all', label: 'Все этапы' },
...DEAL_STAGES.map((s) => ({ value: s, label: DEAL_STAGE_LABELS[s] })), ...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> </script>
<template> <template>
<div class="flex flex-wrap gap-4 items-center"> <div class="flex flex-wrap gap-x-5 gap-y-3 items-center">
<div class="flex gap-2 items-center"> <div class="flex gap-1.5 items-center">
<span class="text-sm text-gray-500">Статус:</span> <span class="text-xs uppercase tracking-wider text-gray-400 mr-1">Статус</span>
<button <button
v-for="s in statuses" v-for="s in statuses"
:key="s.value" :key="s.value"
type="button" type="button"
class="px-3 py-1 rounded-full border text-sm transition" class="px-2.5 py-1 rounded-full border text-xs font-medium transition"
:class=" :class="pill(store.filters.status === s.value)"
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'
"
@click="store.setStatus(s.value)" @click="store.setStatus(s.value)"
> >
{{ s.label }} {{ s.label }}
</button> </button>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-1.5 items-center">
<span class="text-sm text-gray-500">Тип:</span> <span class="text-xs uppercase tracking-wider text-gray-400 mr-1">Тип</span>
<button <button
v-for="t in types" v-for="t in types"
:key="t.value" :key="t.value"
type="button" type="button"
class="px-3 py-1 rounded-full border text-sm transition" class="px-2.5 py-1 rounded-full border text-xs font-medium transition"
:class=" :class="pill(store.filters.lead_type === t.value)"
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'
"
@click="store.setLeadType(t.value)" @click="store.setLeadType(t.value)"
> >
{{ t.label }} {{ t.label }}
</button> </button>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-1.5 items-center">
<span class="text-sm text-gray-500">Этап:</span> <span class="text-xs uppercase tracking-wider text-gray-400 mr-1">Этап</span>
<button <button
v-for="s in stages" v-for="s in stages"
:key="s.value" :key="s.value"
type="button" type="button"
class="px-3 py-1 rounded-full border text-sm transition" class="px-2.5 py-1 rounded-full border text-xs font-medium transition"
:class=" :class="pill(store.filters.deal_stage === s.value)"
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'
"
@click="store.setDealStage(s.value)" @click="store.setDealStage(s.value)"
> >
{{ s.label }} {{ s.label }}

View File

@ -43,10 +43,10 @@ function formatMoney(v: number | null) {
const stageClass: Record<DealStage, string> = { const stageClass: Record<DealStage, string> = {
new: 'bg-gray-100 text-gray-700', new: 'bg-gray-100 text-gray-700',
contacted: 'bg-blue-100 text-blue-800', contacted: 'bg-sky-100 text-sky-700',
proposal: 'bg-amber-100 text-amber-800', proposal: 'bg-amber-100 text-amber-700',
won: 'bg-green-100 text-green-800', won: 'bg-emerald-100 text-emerald-700',
lost: 'bg-red-100 text-red-800', lost: 'bg-red-100 text-red-700',
}; };
async function removeLead(id: string, name: string) { async function removeLead(id: string, name: string) {
@ -62,14 +62,16 @@ async function removeLead(id: string, name: string) {
<template> <template>
<div class="space-y-4"> <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 /> <FilterBar />
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<label class="text-gray-500">Сортировка:</label> <label class="text-xs text-gray-500">Сортировка</label>
<select <select
:value="store.sort" :value="store.sort"
@change="store.setSort(($event.target as HTMLSelectElement).value as LeadSort)" @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"> <option v-for="s in sortOptions" :key="s" :value="s">
{{ LEAD_SORT_LABELS[s] }} {{ LEAD_SORT_LABELS[s] }}
@ -78,66 +80,76 @@ async function removeLead(id: string, name: string) {
</div> </div>
</div> </div>
<div v-if="store.loading" class="text-gray-500">Загрузка…</div> <div v-if="store.loading" class="card p-12 text-center text-gray-400">Загрузка…</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</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 <div
v-else-if="store.items.length === 0" 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 class="text-sm font-medium text-gray-900">Лидов не найдено</div>
<div class="text-xs text-gray-500 mt-1">Попробуйте сбросить фильтры или добавить нового.</div>
</div> </div>
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden"> <div v-else class="card overflow-hidden">
<table class="w-full text-sm"> <table class="table-base">
<thead class="bg-gray-50 text-xs text-gray-500 uppercase"> <thead>
<tr> <tr>
<th class="text-left px-4 py-3 font-medium">Score</th> <th>Score</th>
<th class="text-left px-4 py-3 font-medium">Контакт</th> <th>Контакт</th>
<th class="text-left px-4 py-3 font-medium">Компания</th> <th>Компания</th>
<th class="text-left px-4 py-3 font-medium">Этап</th> <th>Этап</th>
<th class="text-left px-4 py-3 font-medium">Сумма</th> <th class="text-right">Сумма</th>
<th class="text-left px-4 py-3 font-medium">След. касание</th> <th>Касание</th>
<th class="text-left px-4 py-3 font-medium">Тип</th> <th>Тип</th>
<th class="text-left px-4 py-3 font-medium">Автор</th> <th>Автор</th>
<th class="text-right px-4 py-3 font-medium"></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="lead in store.items" v-for="lead in store.items"
:key="lead.id" :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 } })" @click="router.push({ name: 'lead-card', params: { id: lead.id } })"
> >
<td class="px-4 py-3"> <td class="px-4 py-3">
<ScoreBadge :score="lead.score" :status="lead.status" /> <ScoreBadge :score="lead.score" :status="lead.status" />
</td> </td>
<td class="px-4 py-3"> <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> <div v-if="lead.position" class="text-xs text-gray-500">{{ lead.position }}</div>
</td> </td>
<td class="px-4 py-3"> <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> <div v-if="lead.city" class="text-xs text-gray-500">{{ lead.city }}</div>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<span <span class="badge" :class="stageClass[lead.deal_stage]">
class="text-xs font-medium px-2 py-1 rounded-full"
:class="stageClass[lead.deal_stage]"
>
{{ DEAL_STAGE_LABELS[lead.deal_stage] }} {{ DEAL_STAGE_LABELS[lead.deal_stage] }}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-gray-700">{{ formatMoney(lead.expected_amount) }}</td> <td class="px-4 py-3 text-right tabular-nums text-gray-700">
<td class="px-4 py-3 text-gray-600">{{ formatDate(lead.next_action_date) }}</td> {{ formatMoney(lead.expected_amount) }}
<td class="px-4 py-3 text-gray-600">
{{ lead.lead_type === 'inbound' ? 'Inbound' : 'Outbound' }}
</td> </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"> <td class="px-4 py-3 text-right">
<button <button
v-if="auth.isRop" v-if="auth.isRop"
type="button" 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="Удалить" title="Удалить"
@click.stop="removeLead(lead.id, lead.contact_name)" @click.stop="removeLead(lead.id, lead.contact_name)"
> >
@ -151,40 +163,40 @@ async function removeLead(id: string, name: string) {
<div <div
v-if="store.items.length > 0" 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> <div class="flex items-center gap-3">
<label class="mr-2">На странице:</label> <label class="text-xs text-gray-500">На странице</label>
<select <select
:value="store.pageSize" :value="store.pageSize"
@change="store.setPageSize(Number(($event.target as HTMLSelectElement).value))" @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="10">10</option>
<option :value="20">20</option> <option :value="20">20</option>
<option :value="50">50</option> <option :value="50">50</option>
<option :value="100">100</option> <option :value="100">100</option>
</select> </select>
<span class="ml-4 text-gray-500"> <span class="text-xs text-gray-500 tabular-nums">
{{ (store.page - 1) * store.pageSize + 1 }}–{{ {{ (store.page - 1) * store.pageSize + 1 }}–{{
Math.min(store.page * store.pageSize, store.total) Math.min(store.page * store.pageSize, store.total)
}} }}
из {{ store.total }} из {{ store.total }}
</span> </span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-1">
<button <button
type="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" :disabled="store.page <= 1 || store.loading"
@click="store.setPage(store.page - 1)" @click="store.setPage(store.page - 1)"
> >
← ←
</button> </button>
<span class="px-2">{{ store.page }} / {{ store.totalPages }}</span> <span class="px-3 text-xs tabular-nums">{{ store.page }} / {{ store.totalPages }}</span>
<button <button
type="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" :disabled="store.page >= store.totalPages || store.loading"
@click="store.setPage(store.page + 1)" @click="store.setPage(store.page + 1)"
> >

View File

@ -19,17 +19,19 @@ function select(v: T) {
<template> <template>
<div class="space-y-2"> <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"> <div class="flex flex-wrap gap-2">
<button <button
v-for="o in options" v-for="o in options"
:key="String(o.value)" :key="String(o.value)"
type="button" 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=" :class="
props.modelValue === o.value props.modelValue === o.value
? 'bg-gray-900 text-white border-gray-900' ? 'bg-gray-900 text-white border-gray-900 shadow-sm'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50' : 'bg-white text-gray-600 border-gray-200 hover:border-gray-300 hover:text-gray-900'
" "
@click="select(o.value)" @click="select(o.value)"
> >

View File

@ -4,35 +4,46 @@ import type { LeadStatus } from '@/api/leads';
const props = defineProps<{ score: number; status: LeadStatus }>(); const props = defineProps<{ score: number; status: LeadStatus }>();
const styles = computed(() => { const dotColor = computed(() => {
switch (props.status) { switch (props.status) {
case 'cold': case 'cold':
return 'bg-red-100 text-red-700 border-red-300'; return 'bg-sky-400';
case 'warm': case 'warm':
return 'bg-yellow-100 text-yellow-700 border-yellow-300'; return 'bg-amber-400';
case 'hot': 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) { switch (props.status) {
case 'cold': case 'cold':
return '🔴'; return 'text-sky-700';
case 'warm': case 'warm':
return '🟡'; return 'text-amber-700';
case 'hot': 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> </script>
<template> <template>
<span <span
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm font-semibold border" class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-semibold tabular-nums"
:class="styles" :class="[bgColor, textColor]"
> >
<span>{{ icon }}</span> <span class="w-1.5 h-1.5 rounded-full" :class="dotColor"></span>
<span>{{ score }}</span> {{ score }}
</span> </span>
</template> </template>

View File

@ -2,9 +2,87 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
html, body, #app { html,
body,
#app {
height: 100%; height: 100%;
} }
body { 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;
} }

View File

@ -52,10 +52,10 @@ const stageBadgeClass = computed(() => {
if (!lead.value) return ''; if (!lead.value) return '';
const map: Record<DealStage, string> = { const map: Record<DealStage, string> = {
new: 'bg-gray-100 text-gray-700', new: 'bg-gray-100 text-gray-700',
contacted: 'bg-blue-100 text-blue-800', contacted: 'bg-sky-100 text-sky-700',
proposal: 'bg-amber-100 text-amber-800', proposal: 'bg-amber-100 text-amber-700',
won: 'bg-green-100 text-green-800', won: 'bg-emerald-100 text-emerald-700',
lost: 'bg-red-100 text-red-800', lost: 'bg-red-100 text-red-700',
}; };
return map[lead.value.deal_stage]; return map[lead.value.deal_stage];
}); });
@ -180,7 +180,7 @@ onMounted(load);
<div class="max-w-3xl mx-auto space-y-6"> <div class="max-w-3xl mx-auto space-y-6">
<button <button
type="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' })" @click="$router.push({ name: 'leads' })"
> >
← К списку ← К списку
@ -190,7 +190,7 @@ onMounted(load);
<div v-else-if="error" class="text-red-600">{{ error }}</div> <div v-else-if="error" class="text-red-600">{{ error }}</div>
<template v-else-if="lead"> <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="flex items-start justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
<h1 class="text-2xl font-bold truncate">{{ lead.contact_name }}</h1> <h1 class="text-2xl font-bold truncate">{{ lead.contact_name }}</h1>
@ -231,26 +231,26 @@ onMounted(load);
</div> </div>
<div <div
class="rounded-lg p-4" class="rounded-xl p-4 border"
:class="{ :class="{
'bg-red-50 text-red-900': lead.status === 'cold', 'bg-sky-50/60 text-sky-900 border-sky-100': lead.status === 'cold',
'bg-yellow-50 text-yellow-900': lead.status === 'warm', 'bg-amber-50/60 text-amber-900 border-amber-100': lead.status === 'warm',
'bg-green-50 text-green-900': lead.status === 'hot', 'bg-emerald-50/60 text-emerald-900 border-emerald-100': lead.status === 'hot',
}" }"
> >
<p class="font-medium">Рекомендация</p> <p class="text-xs font-semibold uppercase tracking-wider opacity-70">Рекомендация</p>
<p>{{ recommendation }}</p> <p class="mt-1 text-sm">{{ recommendation }}</p>
</div> </div>
</div> </div>
<div class="bg-white rounded-xl border border-gray-200 p-6 space-y-4"> <div class="card-pad space-y-4">
<h2 class="font-semibold">Pipeline</h2> <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 class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium mb-1">Этап воронки</label> <label class="block text-sm font-medium mb-1">Этап воронки</label>
<select <select
v-model="pipelineForm.deal_stage" 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"> <option v-for="s in DEAL_STAGES" :key="s" :value="s">
{{ DEAL_STAGE_LABELS[s] }} {{ DEAL_STAGE_LABELS[s] }}
@ -266,7 +266,7 @@ onMounted(load);
<input <input
v-model="pipelineForm.next_action_date" v-model="pipelineForm.next_action_date"
type="date" type="date"
class="w-full border border-gray-300 rounded-lg px-3 py-2" class="input-base"
/> />
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
@ -274,14 +274,14 @@ onMounted(load);
<textarea <textarea
v-model="pipelineForm.comment" v-model="pipelineForm.comment"
rows="3" rows="3"
class="w-full border border-gray-300 rounded-lg px-3 py-2" class="input-base"
/> />
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
type="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" :disabled="saving"
@click="savePipeline" @click="savePipeline"
> >
@ -299,8 +299,8 @@ onMounted(load);
</div> </div>
</div> </div>
<div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3"> <div class="card-pad space-y-3">
<h2 class="font-semibold">Детализация баллов</h2> <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Детализация баллов</h2>
<ul class="divide-y divide-gray-100"> <ul class="divide-y divide-gray-100">
<li <li
v-for="(pts, key) in lead.breakdown" v-for="(pts, key) in lead.breakdown"
@ -320,8 +320,8 @@ onMounted(load);
</div> </div>
</div> </div>
<div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3"> <div class="card-pad space-y-3">
<h2 class="font-semibold">История изменений score</h2> <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 v-if="history.length === 0" class="text-sm text-gray-500">
История пуста История пуста
</div> </div>
@ -342,7 +342,7 @@ onMounted(load);
<div v-if="auth.isRop" 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="btn-danger !py-1.5 text-xs"
@click="remove" @click="remove"
> >
Удалить лида Удалить лида

View File

@ -13,13 +13,15 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="max-w-6xl mx-auto space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-between"> <div class="flex items-end justify-between">
<h1 class="text-2xl font-bold">Лиды ({{ store.total }})</h1> <div>
<RouterLink <h1 class="text-2xl font-semibold tracking-tight">Лиды</h1>
:to="{ name: 'new-lead' }" <p class="text-sm text-gray-500 mt-1">
class="bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800" Всего {{ store.total }} {{ store.total === 1 ? 'лид' : 'лидов' }}
> </p>
</div>
<RouterLink :to="{ name: 'new-lead' }" class="btn-primary">
+ Добавить лида + Добавить лида
</RouterLink> </RouterLink>
</div> </div>

View File

@ -33,37 +33,55 @@ async function submit() {
</script> </script>
<template> <template>
<div class="max-w-sm mx-auto mt-24 bg-white rounded-xl shadow p-8"> <div class="min-h-[80vh] flex items-center justify-center px-4">
<h1 class="text-2xl font-bold mb-6">Вход</h1> <div class="w-full max-w-sm">
<form class="space-y-4" @submit.prevent="submit"> <div class="flex items-center justify-center gap-2 mb-8">
<div> <div
<label class="block text-sm font-medium mb-1">Логин</label> class="w-10 h-10 rounded-xl bg-gray-900 text-white flex items-center justify-center font-bold text-lg"
<input >
v-model="username" L
type="text" </div>
autocomplete="username" <span class="text-xl font-semibold tracking-tight">Lead Scoring</span>
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-900"
/>
</div> </div>
<div>
<label class="block text-sm font-medium mb-1">Пароль</label> <div class="card-pad shadow-sm">
<input <h1 class="text-lg font-semibold mb-1">Вход</h1>
v-model="password" <p class="text-sm text-gray-500 mb-6">Войдите в свою учётную запись</p>
type="password"
autocomplete="current-password" <form class="space-y-4" @submit.prevent="submit">
required <div>
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-900" <label class="label">Логин</label>
/> <input
v-model="username"
type="text"
autocomplete="username"
required
class="input-base"
placeholder="manager1"
/>
</div>
<div>
<label class="label">Пароль</label>
<input
v-model="password"
type="password"
autocomplete="current-password"
required
class="input-base"
placeholder="••••••••"
/>
</div>
<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>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p> </div>
<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"
>
{{ loading ? 'Вход…' : 'Войти' }}
</button>
</form>
</div> </div>
</template> </template>

View File

@ -23,21 +23,23 @@ watch(
</script> </script>
<template> <template>
<div class="max-w-6xl mx-auto space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-between"> <div>
<div> <RouterLink
<RouterLink :to="{ name: 'stats' }"
:to="{ name: 'stats' }" class="text-xs text-gray-500 hover:text-gray-900 transition inline-flex items-center gap-1"
class="text-sm text-gray-500 hover:text-gray-900" >
> ← К статистике
← К статистике </RouterLink>
</RouterLink> <div class="flex items-end justify-between mt-2">
<h1 class="text-2xl font-bold mt-1"> <div>
Лиды менеджера <h1 class="text-2xl font-semibold tracking-tight">
<span class="text-gray-500 font-normal">·</span> {{ route.params.manager }}
{{ route.params.manager }} </h1>
<span class="text-gray-400 font-normal">({{ store.total }})</span> <p class="text-sm text-gray-500 mt-1">
</h1> Лиды менеджера · {{ store.total }}
</p>
</div>
</div> </div>
</div> </div>
<LeadsTable /> <LeadsTable />

View File

@ -201,11 +201,16 @@ async function submit() {
<template> <template>
<div class="max-w-2xl mx-auto space-y-6"> <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"> <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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium mb-1"> <label class="block text-sm font-medium mb-1">
@ -214,7 +219,7 @@ async function submit() {
<input <input
v-model="form.contact_name" v-model="form.contact_name"
type="text" 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'" :class="displayErrors.contact_name ? 'border-red-500' : 'border-gray-300'"
/> />
<p v-if="displayErrors.contact_name" class="mt-1 text-xs text-red-600"> <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" v-model="form.contact_phone"
type="tel" type="tel"
placeholder="+7 ..." 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'" :class="displayErrors.contact_phone ? 'border-red-500' : 'border-gray-300'"
/> />
<p v-if="displayErrors.contact_phone" class="mt-1 text-xs text-red-600"> <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" v-model="form.email"
type="email" type="email"
placeholder="ivan@acme.com" 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'" :class="displayErrors.email ? 'border-red-500' : 'border-gray-300'"
/> />
<p v-if="displayErrors.email" class="mt-1 text-xs text-red-600"> <p v-if="displayErrors.email" class="mt-1 text-xs text-red-600">
@ -257,14 +262,14 @@ async function submit() {
v-model="form.position" v-model="form.position"
type="text" type="text"
placeholder="CEO / Head of Sales / …" placeholder="CEO / Head of Sales / …"
class="w-full border border-gray-300 rounded-lg px-3 py-2" class="input-base"
/> />
</div> </div>
</div> </div>
</section> </section>
<section class="space-y-4"> <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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="block text-sm font-medium mb-1"> <label class="block text-sm font-medium mb-1">
@ -273,7 +278,7 @@ async function submit() {
<input <input
v-model="form.company_name" v-model="form.company_name"
type="text" 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'" :class="displayErrors.company_name ? 'border-red-500' : 'border-gray-300'"
/> />
<p v-if="displayErrors.company_name" class="mt-1 text-xs text-red-600"> <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" v-model="form.website"
type="url" type="url"
placeholder="https://acme.com" placeholder="https://acme.com"
class="w-full border border-gray-300 rounded-lg px-3 py-2" class="input-base"
/> />
</div> </div>
<div> <div>
@ -297,7 +302,7 @@ async function submit() {
v-model="form.industry" v-model="form.industry"
type="text" type="text"
placeholder="SaaS / Retail / …" 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'" :class="displayErrors.industry ? 'border-red-500' : 'border-gray-300'"
/> />
<p v-if="displayErrors.industry" class="mt-1 text-xs text-red-600"> <p v-if="displayErrors.industry" class="mt-1 text-xs text-red-600">
@ -312,7 +317,7 @@ async function submit() {
v-model="form.city" v-model="form.city"
type="text" type="text"
placeholder="Алматы" placeholder="Алматы"
class="w-full border rounded-lg px-3 py-2" class="input-base"
:class="displayErrors.city ? 'border-red-500' : 'border-gray-300'" :class="displayErrors.city ? 'border-red-500' : 'border-gray-300'"
/> />
<p v-if="displayErrors.city" class="mt-1 text-xs text-red-600"> <p v-if="displayErrors.city" class="mt-1 text-xs text-red-600">
@ -323,7 +328,7 @@ async function submit() {
</section> </section>
<section class="space-y-4"> <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> Скоринг <span class="text-red-600">*</span>
</h2> </h2>
<QuestionButton label="Тип лида" v-model="form.lead_type" :options="leadTypeOpts" /> <QuestionButton label="Тип лида" v-model="form.lead_type" :options="leadTypeOpts" />
@ -382,13 +387,13 @@ async function submit() {
</section> </section>
<section class="space-y-4"> <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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium mb-1">Этап воронки</label> <label class="block text-sm font-medium mb-1">Этап воронки</label>
<select <select
v-model="form.deal_stage" 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"> <option v-for="s in DEAL_STAGES" :key="s" :value="s">
{{ DEAL_STAGE_LABELS[s] }} {{ DEAL_STAGE_LABELS[s] }}
@ -404,7 +409,7 @@ async function submit() {
<input <input
v-model="form.next_action_date" v-model="form.next_action_date"
type="date" type="date"
class="w-full border border-gray-300 rounded-lg px-3 py-2" class="input-base"
/> />
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
@ -413,25 +418,30 @@ async function submit() {
v-model="form.comment" v-model="form.comment"
rows="3" rows="3"
placeholder="О чём договорились, что нужно уточнить…" placeholder="О чём договорились, что нужно уточнить…"
class="w-full border border-gray-300 rounded-lg px-3 py-2" class="input-base"
/> />
</div> </div>
</div> </div>
</section> </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"> <div class="flex gap-3">
<button <button
type="submit" type="submit"
:disabled="!canSubmit || loading" :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 ? 'Сохраняем…' : 'Создать' }} {{ loading ? 'Сохраняем…' : 'Создать' }}
</button> </button>
<button <button
type="button" type="button"
class="text-gray-600 hover:text-gray-900 px-5 py-2" class="btn-ghost"
@click="$router.push({ name: 'leads' })" @click="$router.push({ name: 'leads' })"
> >
Отмена Отмена

View File

@ -31,7 +31,9 @@ async function load() {
onMounted(load); onMounted(load);
function money(v: number) { 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) { function score(v: number) {
@ -40,12 +42,15 @@ function score(v: number) {
</script> </script>
<template> <template>
<div class="max-w-7xl mx-auto space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-between"> <div class="flex items-end justify-between">
<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>
<button <button
type="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" :disabled="loading"
@click="load" @click="load"
> >
@ -53,97 +58,130 @@ function score(v: number) {
</button> </button>
</div> </div>
<div v-if="loading" class="text-gray-500">Загрузка…</div> <div v-if="loading" class="card p-12 text-center text-gray-400">Загрузка…</div>
<div v-else-if="error" class="text-red-600">{{ error }}</div> <div v-else-if="error" class="card p-6 text-red-600">{{ error }}</div>
<template v-else> <template v-else>
<div v-if="totals" class="grid grid-cols-2 md:grid-cols-4 gap-4"> <div v-if="totals" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="bg-white rounded-xl border border-gray-200 p-4"> <div class="card p-5">
<div class="text-xs text-gray-500 uppercase">Всего лидов</div> <div class="text-xs text-gray-500 uppercase tracking-wider">Всего лидов</div>
<div class="text-2xl font-bold mt-1">{{ totals.total }}</div> <div class="text-3xl font-semibold tracking-tight mt-2 tabular-nums">
<div class="text-xs text-gray-500 mt-1"> {{ totals.total }}
+{{ totals.new_7d }} за 7д · +{{ totals.new_30d }} за 30д </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> </div>
<div class="bg-white rounded-xl border border-gray-200 p-4"> <div class="card p-5">
<div class="text-xs text-gray-500 uppercase">Pipeline</div> <div class="text-xs text-gray-500 uppercase tracking-wider">Pipeline</div>
<div class="text-2xl font-bold mt-1">{{ money(totals.pipeline_amount) }}</div> <div class="text-3xl font-semibold tracking-tight mt-2 tabular-nums">
<div class="text-xs text-gray-500 mt-1">в работе: {{ totals.in_progress }}</div> {{ money(totals.pipeline_amount) }}
</div>
<div class="text-xs text-gray-500 mt-2">в работе: {{ totals.in_progress }}</div>
</div> </div>
<div class="bg-white rounded-xl border border-gray-200 p-4"> <div class="card p-5">
<div class="text-xs text-gray-500 uppercase">Выручка (won)</div> <div class="text-xs text-gray-500 uppercase tracking-wider">Выручка</div>
<div class="text-2xl font-bold mt-1 text-green-700"> <div class="text-3xl font-semibold tracking-tight mt-2 tabular-nums text-emerald-700">
{{ money(totals.won_amount) }} {{ money(totals.won_amount) }}
</div> </div>
<div class="text-xs text-gray-500 mt-1"> <div class="text-xs text-gray-500 mt-2">
won: {{ totals.won }} · lost: {{ totals.lost }} <span class="text-emerald-600">won {{ totals.won }}</span>
·
<span class="text-red-500">lost {{ totals.lost }}</span>
</div> </div>
</div> </div>
<div class="bg-white rounded-xl border border-gray-200 p-4"> <div class="card p-5">
<div class="text-xs text-gray-500 uppercase">Просрочено касаний</div> <div class="text-xs text-gray-500 uppercase tracking-wider">Просрочено</div>
<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'" :class="totals.overdue > 0 ? 'text-red-600' : 'text-gray-900'"
> >
{{ totals.overdue }} {{ totals.overdue }}
</div> </div>
<div class="text-xs text-gray-500 mt-1"> <div class="text-xs text-gray-500 mt-2 flex gap-3">
🔥 {{ totals.hot }} · 🌡 {{ totals.warm }} · ❄ {{ totals.cold }} <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>
</div> </div>
<div <div
v-if="managers.length === 0" 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>
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden"> <div v-else class="card overflow-hidden">
<table class="w-full text-sm"> <table class="table-base">
<thead class="bg-gray-50 text-xs text-gray-500 uppercase"> <thead>
<tr> <tr>
<th class="text-left px-4 py-3 font-medium">Менеджер</th> <th>Менеджер</th>
<th class="text-right px-4 py-3 font-medium">Лиды</th> <th class="text-right">Лиды</th>
<th class="text-right px-4 py-3 font-medium">🔥 / 🌡 / ❄</th> <th class="text-right">🔥 / 🌡 / ❄</th>
<th class="text-right px-4 py-3 font-medium">Won / Lost</th> <th class="text-right">Won / Lost</th>
<th class="text-right px-4 py-3 font-medium">Конверсия</th> <th class="text-right">Конв.</th>
<th class="text-right px-4 py-3 font-medium">Avg score</th> <th class="text-right">Avg score</th>
<th class="text-right px-4 py-3 font-medium">Pipeline</th> <th class="text-right">Pipeline</th>
<th class="text-right px-4 py-3 font-medium">Выручка</th> <th class="text-right">Выручка</th>
<th class="text-right px-4 py-3 font-medium">Просрочено</th> <th class="text-right">Просрочено</th>
<th class="text-right px-4 py-3 font-medium">+7д</th> <th class="text-right">+7д</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="m in managers" v-for="m in managers"
:key="m.manager" :key="m.manager"
class="border-t border-gray-100 hover:bg-gray-50 cursor-pointer" class="cursor-pointer"
@click="openManager(m.manager)" @click="openManager(m.manager)"
> >
<td class="px-4 py-3 font-medium text-blue-700 hover:underline"> <td class="px-4 py-3">
{{ m.manager }} → <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>
<td class="px-4 py-3 text-right">{{ m.total }}</td> <td class="px-4 py-3 text-right tabular-nums">{{ m.total }}</td>
<td class="px-4 py-3 text-right text-gray-600"> <td class="px-4 py-3 text-right text-gray-500 tabular-nums">
{{ m.hot }} / {{ m.warm }} / {{ m.cold }} {{ m.hot }} / {{ m.warm }} / {{ m.cold }}
</td> </td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right tabular-nums">
<span class="text-green-700">{{ m.won }}</span> <span class="text-emerald-700">{{ m.won }}</span>
/ <span class="text-gray-300"> / </span>
<span class="text-red-600">{{ m.lost }}</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>
<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 <td
class="px-4 py-3 text-right" class="px-4 py-3 text-right tabular-nums"
:class="m.overdue > 0 ? 'text-red-600 font-medium' : 'text-gray-500'" :class="m.overdue > 0 ? 'text-red-600 font-medium' : 'text-gray-400'"
> >
{{ m.overdue }} {{ m.overdue }}
</td> </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> </tr>
</tbody> </tbody>
</table> </table>