163 lines
4.6 KiB
Vue
163 lines
4.6 KiB
Vue
|
|
<script setup lang="ts">
|
|||
|
|
/**
|
|||
|
|
* Воронка распределения лидов по 14 статусам.
|
|||
|
|
*
|
|||
|
|
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html секция .panel
|
|||
|
|
* с #funnel-title (segmented bar + funnel-list).
|
|||
|
|
* Источник правды для статусов: db/schema.sql:2130 → composables/leadStatuses.ts.
|
|||
|
|
*
|
|||
|
|
* Props:
|
|||
|
|
* - counts: Record<slug, number> — количество лидов в каждом статусе.
|
|||
|
|
* Если не передано — используется mock с равномерным распределением.
|
|||
|
|
*
|
|||
|
|
* Рендер:
|
|||
|
|
* 1. Segmented horizontal bar — каждый сегмент пропорционален count'у статуса
|
|||
|
|
* и закрашен colorHex из lead_statuses.
|
|||
|
|
* 2. funnel-list — 14 строк с цветным dot + name + count, отсортированы по
|
|||
|
|
* убыванию count'а (как в handoff).
|
|||
|
|
*/
|
|||
|
|
import { computed } from 'vue';
|
|||
|
|
import { LEAD_STATUSES } from '../../composables/leadStatuses';
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
counts?: Record<string, number>;
|
|||
|
|
title?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Default counts инлайнятся в withDefaults — Vue SFC compiler требует чтобы
|
|||
|
|
// factory-функция в withDefaults не реферировала модуль-уровневые const'ы
|
|||
|
|
// (checkInvalidScopeReference). Mock-распределение ~247 лидов по 14 статусам.
|
|||
|
|
const props = withDefaults(defineProps<Props>(), {
|
|||
|
|
counts: () => ({
|
|||
|
|
new: 18,
|
|||
|
|
viewed: 14,
|
|||
|
|
worked: 22,
|
|||
|
|
base: 9,
|
|||
|
|
missed: 16,
|
|||
|
|
negotiations: 11,
|
|||
|
|
waiting_payment: 7,
|
|||
|
|
partnership: 4,
|
|||
|
|
paid: 45,
|
|||
|
|
closed: 3,
|
|||
|
|
test_drive: 38,
|
|||
|
|
hot: 5,
|
|||
|
|
replacement: 5,
|
|||
|
|
final_missed: 39,
|
|||
|
|
}),
|
|||
|
|
title: 'Воронка',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const items = computed(() =>
|
|||
|
|
LEAD_STATUSES.map((s) => ({
|
|||
|
|
...s,
|
|||
|
|
count: props.counts[s.slug] ?? 0,
|
|||
|
|
})),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const total = computed(() => items.value.reduce((sum, i) => sum + i.count, 0));
|
|||
|
|
|
|||
|
|
// Список под сегментированной полосой — отсортирован по убыванию count'а
|
|||
|
|
// (как в handoff — наглядность распределения).
|
|||
|
|
const itemsByCount = computed(() => [...items.value].sort((a, b) => b.count - a.count));
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<v-card variant="outlined" class="funnel-chart pa-0">
|
|||
|
|
<div class="panel-head pa-4">
|
|||
|
|
<h2 class="text-h6 panel-title ma-0">{{ props.title }}</h2>
|
|||
|
|
<div class="text-body-2 text-medium-emphasis">{{ items.length }} статусов · {{ total }} лидов · сейчас</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div
|
|||
|
|
class="funnel-bar mx-4"
|
|||
|
|
role="img"
|
|||
|
|
:aria-label="`Распределение ${total} лидов по ${items.length} статусам`"
|
|||
|
|
>
|
|||
|
|
<div
|
|||
|
|
v-for="item in items"
|
|||
|
|
:key="item.slug"
|
|||
|
|
class="funnel-seg"
|
|||
|
|
:style="{ flex: item.count, background: item.colorHex }"
|
|||
|
|
:title="`${item.nameRu} · ${item.count}`"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="funnel-list pa-4">
|
|||
|
|
<span v-for="item in itemsByCount" :key="item.slug" class="funnel-list-item">
|
|||
|
|
<span class="dot" :style="{ background: item.colorHex }" />
|
|||
|
|
<span class="name">{{ item.nameRu }}</span>
|
|||
|
|
<span class="qty">{{ item.count }}</span>
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</v-card>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.funnel-chart {
|
|||
|
|
background: #fff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel-head {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: flex-start;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel-title {
|
|||
|
|
font-variation-settings: 'opsz' 18;
|
|||
|
|
letter-spacing: -0.01em;
|
|||
|
|
color: #081319;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.funnel-bar {
|
|||
|
|
display: flex;
|
|||
|
|
height: 12px;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
background: #f0ede4;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.funnel-seg {
|
|||
|
|
height: 100%;
|
|||
|
|
transition: filter 0.15s;
|
|||
|
|
}
|
|||
|
|
.funnel-seg:hover {
|
|||
|
|
filter: brightness(1.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.funnel-list {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(2, 1fr);
|
|||
|
|
gap: 8px 16px;
|
|||
|
|
border-top: 1px solid #f0ede4;
|
|||
|
|
margin-top: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.funnel-list-item {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: 12px 1fr auto;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 10px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dot {
|
|||
|
|
width: 10px;
|
|||
|
|
height: 10px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.name {
|
|||
|
|
color: #343c41;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.qty {
|
|||
|
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
|||
|
|
font-feature-settings: 'tnum';
|
|||
|
|
font-weight: 500;
|
|||
|
|
color: #081319;
|
|||
|
|
}
|
|||
|
|
</style>
|