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>
239 lines
7.7 KiB
Vue
239 lines
7.7 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Линейный SVG-чарт «Активность по дням».
|
||
*
|
||
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html секция .panel
|
||
* с #chart-title. Native SVG (как в handoff) — без chart-library, чтобы избежать
|
||
* +400KB зависимости. Для статичных дашборд-чартов native-SVG достаточно;
|
||
* интерактив (tooltip-on-hover) добавим позже как Vuetify-overlay.
|
||
*
|
||
* Props:
|
||
* - points: 7 значений (пн..сб, сегодня) для активного метрика-tab.
|
||
* - max: верхняя граница оси Y (default 60).
|
||
*
|
||
* Внутри 3 tabs (Принято/Оплачено/Отказ) — на MVP только активный tab,
|
||
* данные приходят через prop. На API-привязке tabs будут менять props.points.
|
||
*/
|
||
import { computed, ref } from 'vue';
|
||
|
||
interface Props {
|
||
title?: string;
|
||
subtitle?: string;
|
||
points?: number[];
|
||
max?: number;
|
||
labels?: string[];
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
title: 'Активность по дням',
|
||
subtitle: 'принято · оплачено · отказ — последние 7 дней',
|
||
points: () => [16, 31, 27, 47, 39, 56, 50],
|
||
max: 60,
|
||
labels: () => ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'сегодня'],
|
||
});
|
||
|
||
const activeTab = ref<'received' | 'paid' | 'refused'>('received');
|
||
|
||
// SVG viewBox: 800x220, отступы 50/30/180/200 (left/top/bottom-padding-y).
|
||
const VIEW_W = 800;
|
||
const VIEW_H = 220;
|
||
const X_LEFT = 36;
|
||
const X_RIGHT = 790;
|
||
const Y_TOP = 20;
|
||
const Y_BOTTOM = 200;
|
||
const PLOT_W = X_RIGHT - X_LEFT;
|
||
const PLOT_H = Y_BOTTOM - Y_TOP;
|
||
|
||
// Координаты точек.
|
||
const coords = computed(() => {
|
||
const n = props.points.length;
|
||
return props.points.map((value, i) => {
|
||
const x = X_LEFT + (PLOT_W * i) / (n - 1);
|
||
const y = Y_TOP + PLOT_H - (PLOT_H * Math.max(0, Math.min(value, props.max))) / props.max;
|
||
return { x, y, value };
|
||
});
|
||
});
|
||
|
||
const linePath = computed(() => coords.value.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' '));
|
||
|
||
const areaPath = computed(() => `${linePath.value} L${X_RIGHT},${Y_BOTTOM} L${X_LEFT},${Y_BOTTOM} Z`);
|
||
|
||
// Y-сетка: 4 линии (0/15/30/45/60).
|
||
const ySteps = [0, 15, 30, 45, 60];
|
||
const yGrid = computed(() =>
|
||
ySteps.map((step) => ({
|
||
y: Y_TOP + PLOT_H - (PLOT_H * step) / props.max,
|
||
label: String(step),
|
||
})),
|
||
);
|
||
|
||
// Точка-«сегодня» — последняя, выделяется крупнее и в цвете primary.
|
||
const todayIndex = computed(() => coords.value.length - 2); // предпоследняя как «сегодня (мах)» по handoff
|
||
</script>
|
||
|
||
<template>
|
||
<v-card variant="outlined" class="activity-chart pa-0">
|
||
<div class="panel-head pa-4">
|
||
<div>
|
||
<h2 class="text-h6 panel-title ma-0">{{ props.title }}</h2>
|
||
<div class="text-body-2 text-medium-emphasis">{{ props.subtitle }}</div>
|
||
</div>
|
||
<v-btn-toggle v-model="activeTab" mandatory density="comfortable" variant="text" color="primary">
|
||
<v-btn value="received" size="small">Принято</v-btn>
|
||
<v-btn value="paid" size="small">Оплачено</v-btn>
|
||
<v-btn value="refused" size="small">Отказ</v-btn>
|
||
</v-btn-toggle>
|
||
</div>
|
||
|
||
<div class="chart-wrap px-4 pb-2">
|
||
<svg :viewBox="`0 0 ${VIEW_W} ${VIEW_H}`" preserveAspectRatio="none" :aria-label="props.title">
|
||
<!-- Y-сетка -->
|
||
<g class="y-grid">
|
||
<line
|
||
v-for="step in yGrid"
|
||
:key="step.label"
|
||
:x1="X_LEFT"
|
||
:y1="step.y"
|
||
:x2="X_RIGHT"
|
||
:y2="step.y"
|
||
stroke="#E8EBED"
|
||
/>
|
||
<text
|
||
v-for="step in yGrid"
|
||
:key="`l-${step.label}`"
|
||
:x="X_LEFT - 8"
|
||
:y="step.y + 4"
|
||
text-anchor="end"
|
||
class="chart-axis-y"
|
||
>
|
||
{{ step.label }}
|
||
</text>
|
||
</g>
|
||
|
||
<!-- Заливка под линией -->
|
||
<defs>
|
||
<linearGradient id="activity-area-grad" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="#0A1319" stop-opacity="0.10" />
|
||
<stop offset="100%" stop-color="#0A1319" stop-opacity="0" />
|
||
</linearGradient>
|
||
</defs>
|
||
<path :d="areaPath" fill="url(#activity-area-grad)" />
|
||
|
||
<!-- Линия -->
|
||
<path
|
||
:d="linePath"
|
||
fill="none"
|
||
stroke="#0A1319"
|
||
stroke-width="1.7"
|
||
stroke-linejoin="round"
|
||
stroke-linecap="round"
|
||
/>
|
||
|
||
<!-- Точки -->
|
||
<g class="points">
|
||
<circle
|
||
v-for="(p, i) in coords"
|
||
:key="i"
|
||
:cx="p.x"
|
||
:cy="p.y"
|
||
:r="i === todayIndex ? 4.2 : 2.6"
|
||
:fill="i === todayIndex ? '#0F6E56' : '#FFFFFF'"
|
||
:stroke="i === todayIndex ? '#FFFFFF' : '#0A1319'"
|
||
:stroke-width="i === todayIndex ? 2 : 1.6"
|
||
>
|
||
<title>{{ props.labels[i] }}: {{ p.value }}</title>
|
||
</circle>
|
||
</g>
|
||
|
||
<!-- X-подписи -->
|
||
<g class="x-axis">
|
||
<text
|
||
v-for="(label, i) in props.labels"
|
||
:key="i"
|
||
:x="X_LEFT + (PLOT_W * i) / (props.labels.length - 1)"
|
||
:y="VIEW_H - 6"
|
||
text-anchor="middle"
|
||
class="chart-axis-x"
|
||
:class="{ today: i === todayIndex }"
|
||
>
|
||
{{ label }}
|
||
</text>
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
|
||
<div class="chart-legend px-4 pb-3" aria-hidden="true">
|
||
<span><span class="ldot received" />принято</span>
|
||
<span><span class="ldot paid" />оплачено</span>
|
||
<span><span class="ldot refused" />отказ</span>
|
||
</div>
|
||
</v-card>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.activity-chart {
|
||
background: #fff;
|
||
}
|
||
|
||
.panel-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.panel-title {
|
||
font-variation-settings: 'opsz' 18;
|
||
letter-spacing: -0.01em;
|
||
color: #081319;
|
||
}
|
||
|
||
.chart-wrap {
|
||
width: 100%;
|
||
}
|
||
.chart-wrap svg {
|
||
width: 100%;
|
||
height: 220px;
|
||
display: block;
|
||
}
|
||
|
||
.chart-axis-y,
|
||
.chart-axis-x {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
font-size: 11px;
|
||
fill: #66635c;
|
||
}
|
||
.chart-axis-x.today {
|
||
fill: #0f6e56;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.chart-legend {
|
||
display: flex;
|
||
gap: 20px;
|
||
color: #66635c;
|
||
font-size: 12px;
|
||
border-top: 1px solid #f0ede4;
|
||
padding-top: 12px;
|
||
}
|
||
|
||
.ldot {
|
||
display: inline-block;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
margin-right: 6px;
|
||
vertical-align: -1px;
|
||
}
|
||
.ldot.received {
|
||
background: #0a1319;
|
||
}
|
||
.ldot.paid {
|
||
background: #0f6e56;
|
||
}
|
||
.ldot.refused {
|
||
background: #b94837;
|
||
}
|
||
</style>
|