Files
portal/app/resources/js/components/charts/ActivityChart.vue
T
Дмитрий 290e7dbc34 phase2(charts): ActivityChart + FunnelChart - Dashboard закрыт по дизайну
- 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>
2026-05-08 17:32:58 +03:00

239 lines
7.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>