fix
This commit is contained in:
@@ -160,7 +160,7 @@
|
||||
<div class="text-[14px] font-semibold text-green-700">{{ marketYoYGrowthValue }}</div>
|
||||
</div>
|
||||
<div class="h-[260px]">
|
||||
<Chart type="line" :data="marketDynamicsLineData" :options="marketDynamicsLineOptions" />
|
||||
<Chart type="line" :data="marketDynamicsLineData" :options="marketDynamicsLineOptions" :plugins="barPlugins" />
|
||||
</div>
|
||||
<!-- <div class="mt-4 pt-4 border-t border-slate-200/70">
|
||||
<div class="overflow-hidden rounded-xl border border-slate-200/70">
|
||||
@@ -278,7 +278,7 @@
|
||||
></i>
|
||||
</div>
|
||||
<div class="h-[240px]">
|
||||
<Chart type="bar" :data="ageBarData" :options="compactBarOptions" />
|
||||
<Chart type="bar" :data="ageBarData" :options="compactBarOptions" :plugins="barPlugins" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -292,7 +292,7 @@
|
||||
></i>
|
||||
</div>
|
||||
<div class="h-[220px]">
|
||||
<Chart type="bar" :data="incomeBarData" :options="compactBarOptions" />
|
||||
<Chart type="bar" :data="incomeBarData" :options="compactBarOptions" :plugins="barPlugins" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,7 +354,7 @@
|
||||
<div class="text-[12px] text-slate-500">Бенчмарк</div>
|
||||
</div>
|
||||
<div class="h-[280px]">
|
||||
<Chart type="bar" :data="visibilityBarData" :options="visibilityBarOptions" />
|
||||
<Chart type="bar" :data="visibilityBarData" :options="visibilityBarOptions" :plugins="barPlugins" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -437,7 +437,7 @@
|
||||
></i>
|
||||
</div>
|
||||
<div class="h-[260px]">
|
||||
<Chart type="doughnut" :data="queryTypesDonutData" :options="queryTypesDonutOptions" />
|
||||
<Chart type="doughnut" :data="queryTypesDonutData" :options="queryTypesDonutOptions" :plugins="donutPlugins" />
|
||||
</div>
|
||||
<!-- Метрики под диаграммой -->
|
||||
<div v-if="queryTypesMetrics.length > 0" class="mt-4 pt-4 border-t border-slate-200/70">
|
||||
@@ -465,7 +465,7 @@
|
||||
></i>
|
||||
</div>
|
||||
<div class="h-[220px]">
|
||||
<Chart type="bar" :data="decisionFactorsBarData" :options="compactBarOptions" />
|
||||
<Chart type="bar" :data="decisionFactorsBarData" :options="compactBarOptions" :plugins="barPlugins" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -479,7 +479,7 @@
|
||||
></i>
|
||||
</div>
|
||||
<div class="h-[200px]">
|
||||
<Chart type="bar" :data="refusalReasonsBarData" :options="compactBarOptions" />
|
||||
<Chart type="bar" :data="refusalReasonsBarData" :options="compactBarOptions" :plugins="barPlugins" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -528,37 +528,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-8">
|
||||
<PptFunnel
|
||||
:steps="pptFunnelSteps"
|
||||
title="Потери на воронке"
|
||||
description="Абсолютные показатели воронки: сколько человек пришло с рекламы, сколько стали лидами, сколько купили. Визуализирует потери клиентов на каждом этапе."
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 lg:col-span-4">
|
||||
<div class="col-span-12">
|
||||
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4 h-full">
|
||||
<div class="flex items-center gap-1.5 mb-3">
|
||||
<div class="text-[16px] font-semibold text-slate-800">Основные проблемы на воронке</div>
|
||||
<div class="flex items-center gap-1.5 mb-2">
|
||||
<div class="text-[16px] font-semibold text-slate-800">Воронка продаж</div>
|
||||
<i
|
||||
class="pi pi-question-circle text-[12px] text-slate-400 cursor-help"
|
||||
v-tooltip.top="'Текстовая расшифровка причин потерь клиентов на каждом этапе. Помогает понять, где именно теряются клиенты и что нужно исправить.'"
|
||||
v-tooltip.top="'Абсолютные показатели воронки: сколько человек пришло с рекламы, сколько стали лидами, сколько купили. Визуализирует потери клиентов на каждом этапе.'"
|
||||
></i>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-xl border border-slate-200/70">
|
||||
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
|
||||
<div class="col-span-5 px-3 py-2">Этап</div>
|
||||
<div class="col-span-7 px-3 py-2">Проблема</div>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-200/70">
|
||||
<div class="grid grid-cols-12 px-3 py-3">
|
||||
<div class="col-span-5 text-[13px] text-slate-800">Лид → Запись</div>
|
||||
<div class="col-span-7 text-[13px] text-slate-600">{{ report?.sections?.customer_journey?.funnel_bottlenecks?.lead_to_booking }}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-12 px-3 py-3">
|
||||
<div class="col-span-5 text-[13px] text-slate-800">Запись → Оплата</div>
|
||||
<div class="col-span-7 text-[13px] text-slate-600">{{ report?.sections?.customer_journey?.funnel_bottlenecks?.booking_to_payment }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-[320px]">
|
||||
<Chart type="bar" :data="funnelBarData" :options="funnelBarOptions" :plugins="barPlugins" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -615,7 +595,7 @@
|
||||
></i>
|
||||
</div>
|
||||
<div class="h-[280px]">
|
||||
<Chart type="bar" :data="channelSharesBarData" :options="channelSharesBarOptions" />
|
||||
<Chart type="bar" :data="channelSharesBarData" :options="channelSharesBarOptions" :plugins="barPlugins" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1265,61 +1245,109 @@ const ageRows = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const barValuesPlugin = {
|
||||
id: 'barValues',
|
||||
afterDatasetsDraw(chart) {
|
||||
const { ctx, data } = chart;
|
||||
ctx.save();
|
||||
ctx.font = 'bold 15px Inter, system-ui, sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const meta = chart.getDatasetMeta(i);
|
||||
if (!meta.hidden) {
|
||||
meta.data.forEach((bar, index) => {
|
||||
const value = dataset.data[index];
|
||||
if (value !== null && value !== undefined && value !== 0) {
|
||||
const unit = dataset.unit || '';
|
||||
let label = value.toLocaleString();
|
||||
if (typeof value === 'number' && !Number.isInteger(value) && value < 10) {
|
||||
label = value.toFixed(2);
|
||||
}
|
||||
label += unit;
|
||||
const isHorizontal = chart.options.indexAxis === 'y';
|
||||
|
||||
if (isHorizontal) {
|
||||
const barWidth = Math.abs(bar.x - bar.base);
|
||||
if (barWidth > 60) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(label, bar.x - 12, bar.y);
|
||||
} else {
|
||||
ctx.fillStyle = '#64748b';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(label, bar.x + 10, bar.y);
|
||||
}
|
||||
} else {
|
||||
const barHeight = Math.abs(bar.base - bar.y);
|
||||
ctx.textAlign = 'center';
|
||||
if (barHeight > 35) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 18px Inter, system-ui, sans-serif';
|
||||
ctx.fillText(label, bar.x, bar.y + 20);
|
||||
} else {
|
||||
ctx.fillStyle = '#64748b';
|
||||
ctx.font = 'bold 14px Inter, system-ui, sans-serif';
|
||||
ctx.fillText(label, bar.x, bar.y - 15);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
const donutPercentPlugin = {
|
||||
id: 'donutPercent',
|
||||
afterDatasetsDraw(chart) {
|
||||
const ctx = chart.ctx;
|
||||
const data = chart.data.datasets[0]?.data || [];
|
||||
const total = data.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (total === 0) return;
|
||||
|
||||
chart.data.datasets.forEach((dataset, i) => {
|
||||
const meta = chart.getDatasetMeta(i);
|
||||
meta.data.forEach((element, index) => {
|
||||
const value = dataset.data[index];
|
||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
|
||||
|
||||
if (parseFloat(percentage) < 3) return;
|
||||
|
||||
const arc = element;
|
||||
const { x, y, startAngle, endAngle, innerRadius, outerRadius } = arc;
|
||||
|
||||
const angle = (startAngle + endAngle) / 2;
|
||||
const radius = (innerRadius + outerRadius) / 2;
|
||||
|
||||
const centerX = x + Math.cos(angle) * radius;
|
||||
const centerY = y + Math.sin(angle) * radius;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px Inter, system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.4)';
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.shadowOffsetX = 1;
|
||||
ctx.shadowOffsetY = 1;
|
||||
ctx.fillText(`${percentage}%`, centerX, centerY);
|
||||
ctx.restore();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const barPlugins = [barValuesPlugin];
|
||||
const donutPlugins = [donutPercentPlugin];
|
||||
|
||||
const compactBarOptions = computed(() => ({
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
barValues: {
|
||||
id: 'barValues',
|
||||
afterDatasetsDraw(chart) {
|
||||
const { ctx, data } = chart;
|
||||
ctx.save();
|
||||
ctx.font = 'bold 12px Inter, system-ui, sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const meta = chart.getDatasetMeta(i);
|
||||
if (!meta.hidden) {
|
||||
meta.data.forEach((bar, index) => {
|
||||
const value = dataset.data[index];
|
||||
if (value !== null && value !== undefined && value !== 0) {
|
||||
const unit = dataset.unit || '';
|
||||
const label = value.toLocaleString() + unit;
|
||||
const isHorizontal = chart.options.indexAxis === 'y';
|
||||
|
||||
if (isHorizontal) {
|
||||
const barWidth = Math.abs(bar.x - bar.base);
|
||||
if (barWidth > 40) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 12px Inter, system-ui, sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(label, bar.x - 12, bar.y);
|
||||
} else {
|
||||
ctx.fillStyle = '#64748b';
|
||||
ctx.font = 'bold 11px Inter, system-ui, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(label, bar.x + 10, bar.y);
|
||||
}
|
||||
} else {
|
||||
const barHeight = Math.abs(bar.base - bar.y);
|
||||
ctx.textAlign = 'center';
|
||||
if (barHeight > 25) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px Inter, system-ui, sans-serif';
|
||||
ctx.fillText(label, bar.x, bar.y + 16);
|
||||
} else {
|
||||
ctx.fillStyle = '#64748b';
|
||||
ctx.font = 'bold 12px Inter, system-ui, sans-serif';
|
||||
ctx.fillText(label, bar.x, bar.y - 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#64748B', font: { size: 11 } }, grid: { display: false, drawBorder: false } },
|
||||
@@ -1831,6 +1859,43 @@ const channelSharesTableRows = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const funnelBarData = computed(() => {
|
||||
const fm = report.value?.sections?.customer_journey?.funnel_metrics ?? {};
|
||||
const labels = ['Охват/Трафик', 'Визиты на сайт', 'Лиды', 'Записи/Бронь', 'Оплаты'];
|
||||
const values = [fm.ad_traffic, fm.site_visitors, fm.leads, fm.bookings, fm.payments].map((v) => {
|
||||
if (typeof v === 'string') return Number(v.replace(/[^\d.]/g, ''));
|
||||
return Number(v) || 0;
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data: values,
|
||||
borderRadius: 10,
|
||||
backgroundColor: (context) => {
|
||||
const { chart } = context;
|
||||
const { ctx, chartArea } = chart;
|
||||
if (!chartArea) return PPT_BLUE;
|
||||
return makeVerticalGradient(ctx, chartArea, PPT_GRADIENT_BLUE_START, PPT_GRADIENT_BLUE_END);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
const funnelBarOptions = computed(() => {
|
||||
const base = compactBarOptions.value;
|
||||
return {
|
||||
...base,
|
||||
indexAxis: 'y',
|
||||
scales: {
|
||||
x: { ...base.scales.x, grid: { color: 'rgba(148,163,184,0.15)', drawBorder: false } },
|
||||
y: { ...base.scales.y, grid: { display: false, drawBorder: false } }
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const channelSharesBarOptions = computed(() => {
|
||||
const base = compactBarOptions.value;
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user