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">
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user