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