Files
portal/app/resources/js/components/charts/FunnelChart.vue
T

163 lines
4.6 KiB
Vue
Raw Normal View History

<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>