ux(leads): summary cards + greeting on the main page

LeadsListView now shows:
- A greeting line ("Доброе утро, manager1") with role-aware copy.
- Four summary tiles: hot / warm / cold counts + pipeline sum
  (open deals only, excludes won/lost). Each status tile is a button
  that toggles the corresponding status filter on the table; the
  active filter is highlighted with a coloured ring.
- The pipeline tile shows the compact-money format (80M / 1.5M / 500K)
  with the exact amount in the title attribute.

Counts come from a single listLeads(limit=500) request on mount.
Sufficient for the PRD ceiling of 10k leads; a dedicated stats
endpoint can replace this later if the cap grows.
This commit is contained in:
Telecom 2026-05-22 13:39:13 +05:00
parent 169eef90f6
commit 18b36dd3d7

View File

@ -1,30 +1,172 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import { useLeadsStore } from '@/stores/leads'; import { useLeadsStore } from '@/stores/leads';
import { useAuthStore } from '@/stores/auth';
import LeadsTable from '@/components/LeadsTable.vue'; import LeadsTable from '@/components/LeadsTable.vue';
import { listLeads, type LeadStatus } from '@/api/leads';
const store = useLeadsStore(); const store = useLeadsStore();
const auth = useAuthStore();
interface SummaryCounts {
total: number;
hot: number;
warm: number;
cold: number;
pipeline: number;
}
const summary = ref<SummaryCounts>({ total: 0, hot: 0, warm: 0, cold: 0, pipeline: 0 });
const summaryLoading = ref(false);
async function loadSummary() {
summaryLoading.value = true;
try {
// Pull a wide page (limit=500 is server max) just to compute counts
// and pipeline sum on the client. For 10k+ leads we'd need a
// dedicated endpoint; current PRD ceiling is 10k.
const r = await listLeads({ limit: 500 });
let hot = 0;
let warm = 0;
let cold = 0;
let pipeline = 0;
for (const lead of r.data) {
if (lead.status === 'hot') hot++;
else if (lead.status === 'warm') warm++;
else cold++;
if (
lead.expected_amount &&
lead.deal_stage !== 'won' &&
lead.deal_stage !== 'lost'
) {
pipeline += lead.expected_amount;
}
}
summary.value = { total: r.total, hot, warm, cold, pipeline };
} catch {
// silent: card just shows zeros
} finally {
summaryLoading.value = false;
}
}
function fmtMoney(v: number): string {
if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1).replace('.0', '') + 'B ₸';
if (v >= 1_000_000) {
const m = v / 1_000_000;
return (m >= 10 ? Math.round(m) : Number(m.toFixed(1)).toString()) + 'M ₸';
}
if (v >= 10_000) return Math.round(v / 1_000) + 'K ₸';
if (v === 0) return '0 ₸';
return v.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' ₸';
}
function selectStatus(s: LeadStatus | 'all') {
store.setStatus(s);
}
const greeting = computed(() => {
const hour = new Date().getHours();
if (hour < 6) return 'Доброй ночи';
if (hour < 12) return 'Доброе утро';
if (hour < 18) return 'Добрый день';
return 'Добрый вечер';
});
const isFilteredByStatus = computed(() => store.filters.status);
onMounted(async () => { onMounted(async () => {
store.reset(); store.reset();
await store.load(); await Promise.all([store.load(), loadSummary()]);
}); });
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-end justify-between"> <div class="flex items-end justify-between gap-4 flex-wrap">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight">Лиды</h1> <h1 class="text-2xl font-semibold tracking-tight">
{{ greeting }}, {{ auth.user?.username }}
</h1>
<p class="text-sm text-gray-500 mt-1"> <p class="text-sm text-gray-500 mt-1">
Всего {{ store.total }} {{ store.total === 1 ? 'лид' : 'лидов' }} {{
auth.isRop
? 'Все лиды команды'
: 'Ваши лиды — отсортированы по приоритету'
}}
</p> </p>
</div> </div>
<RouterLink :to="{ name: 'new-lead' }" class="btn-primary"> <RouterLink :to="{ name: 'new-lead' }" class="btn-primary">
+ Добавить лида + Добавить лида
</RouterLink> </RouterLink>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<button
type="button"
class="card-pad text-left transition hover:shadow-md hover:-translate-y-0.5"
:class="{ 'ring-2 ring-emerald-500': isFilteredByStatus === 'hot' }"
@click="selectStatus(isFilteredByStatus === 'hot' ? 'all' : 'hot')"
>
<div class="flex items-center gap-2 text-xs uppercase tracking-wider text-gray-500">
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
Горячие
</div>
<div class="mt-2 text-3xl font-bold text-emerald-600 tabular-nums">
{{ summary.hot }}
</div>
<div class="mt-1 text-[11px] text-gray-400">Звонить сегодня</div>
</button>
<button
type="button"
class="card-pad text-left transition hover:shadow-md hover:-translate-y-0.5"
:class="{ 'ring-2 ring-amber-500': isFilteredByStatus === 'warm' }"
@click="selectStatus(isFilteredByStatus === 'warm' ? 'all' : 'warm')"
>
<div class="flex items-center gap-2 text-xs uppercase tracking-wider text-gray-500">
<span class="w-2 h-2 rounded-full bg-amber-500"></span>
Тёплые
</div>
<div class="mt-2 text-3xl font-bold text-amber-600 tabular-nums">
{{ summary.warm }}
</div>
<div class="mt-1 text-[11px] text-gray-400">Прогрев / nurture</div>
</button>
<button
type="button"
class="card-pad text-left transition hover:shadow-md hover:-translate-y-0.5"
:class="{ 'ring-2 ring-sky-500': isFilteredByStatus === 'cold' }"
@click="selectStatus(isFilteredByStatus === 'cold' ? 'all' : 'cold')"
>
<div class="flex items-center gap-2 text-xs uppercase tracking-wider text-gray-500">
<span class="w-2 h-2 rounded-full bg-sky-400"></span>
Холодные
</div>
<div class="mt-2 text-3xl font-bold text-sky-600 tabular-nums">
{{ summary.cold }}
</div>
<div class="mt-1 text-[11px] text-gray-400">Низкий приоритет</div>
</button>
<div class="card-pad">
<div class="text-xs uppercase tracking-wider text-gray-500">
Pipeline
</div>
<div
class="mt-2 text-3xl font-bold text-gray-900 tabular-nums"
:title="summary.pipeline.toLocaleString('ru-RU') + ' ₸'"
>
{{ fmtMoney(summary.pipeline) }}
</div>
<div class="mt-1 text-[11px] text-gray-400">
Открытые сделки · {{ summary.total }} лидов
</div>
</div>
</div>
<LeadsTable /> <LeadsTable />
</div> </div>
</template> </template>