diff --git a/src/views/pages/MarketingAnalysis.vue b/src/views/pages/MarketingAnalysis.vue index a2b5572..2b4cf47 100644 --- a/src/views/pages/MarketingAnalysis.vue +++ b/src/views/pages/MarketingAnalysis.vue @@ -160,7 +160,7 @@
{{ marketYoYGrowthValue }}
- +
@@ -465,7 +465,7 @@ >
- +
@@ -479,7 +479,7 @@ >
- +
@@ -528,37 +528,17 @@ /> -
- -
-
+
-
-
Основные проблемы на воронке
+
+
Воронка продаж
-
-
-
Этап
-
Проблема
-
-
-
-
Лид → Запись
-
{{ report?.sections?.customer_journey?.funnel_bottlenecks?.lead_to_booking }}
-
-
-
Запись → Оплата
-
{{ report?.sections?.customer_journey?.funnel_bottlenecks?.booking_to_payment }}
-
-
+
+
@@ -615,7 +595,7 @@ >
- +
@@ -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 {