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:
parent
169eef90f6
commit
18b36dd3d7
@ -1,30 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { useLeadsStore } from '@/stores/leads';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import LeadsTable from '@/components/LeadsTable.vue';
|
||||
import { listLeads, type LeadStatus } from '@/api/leads';
|
||||
|
||||
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 () => {
|
||||
store.reset();
|
||||
await store.load();
|
||||
await Promise.all([store.load(), loadSummary()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between">
|
||||
<div class="flex items-end justify-between gap-4 flex-wrap">
|
||||
<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">
|
||||
Всего {{ store.total }} {{ store.total === 1 ? 'лид' : 'лидов' }}
|
||||
{{
|
||||
auth.isRop
|
||||
? 'Все лиды команды'
|
||||
: 'Ваши лиды — отсортированы по приоритету'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'new-lead' }" class="btn-primary">
|
||||
+ Добавить лида
|
||||
</RouterLink>
|
||||
</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 />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user