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 {