290e7dbc34
- ActivityChart: native SVG line chart (без chart-library, чтобы не +400KB зависимость для статичных дашборд-графиков). Y-grid 5 линий, area-gradient, 7 точек (предпоследняя выделена primary teal как «сегодня»), 3 tabs (Принято/Оплачено/Отказ). - FunnelChart: segmented bar по 14 статусам + funnel-list (sort desc). - composables/leadStatuses.ts: snapshot 14 статусов из db/schema.sql:2130 (НЕ из BRANDBOOK §3.6 - расхождение #1 handoff vs ТЗ из реестра v1.13). 14 правильных slug'ов: new/viewed/worked/base/missed/negotiations/ waiting_payment/partnership/paid/closed/test_drive/hot/replacement/final_missed. - DashboardView интегрирует оба чарта в charts-row (md=7+5). - cspell-words.txt: ldot, композаблом, инлайнингом, инлайнены. Vue compiler quirk: withDefaults factory не разрешает референсить module-level const'ы (checkInvalidScopeReference). Обходим инлайнингом литерала. Vitest +13 тестов (всего 48/48 за 5.5s): - ActivityChart 6 (3 tabs + 7 circles + 'сегодня' + custom points + legend) - FunnelChart 7 (14 segments + 14 list-items + assertion на отсутствие 'Думает'/'Спам' из handoff + сортировка + colorHex + total) Stories +2 с 3 variants каждый (Histoire 10/14 за 30.43s). Регресс: lint+type-check+format OK; vitest 48/48; vite build (DashboardView chunk 14.9->21.17 KB с чартами); story:build 10/14 за 30.43s; Pest 48/48 за 5.10s. CLAUDE.md v1.21->v1.22, реестр Открытых_вопросов v1.30->v1.31. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
|