From f8950a240bc08becc1bb0af731cc3481d8246d4a Mon Sep 17 00:00:00 2001 From: root Date: Sun, 25 Jan 2026 22:18:27 +0500 Subject: [PATCH] . --- src/views/pages/MarketingAnalysis.vue | 498 ++++++++++++++++++++------ 1 file changed, 383 insertions(+), 115 deletions(-) diff --git a/src/views/pages/MarketingAnalysis.vue b/src/views/pages/MarketingAnalysis.vue index a58c80d..c58b4a5 100644 --- a/src/views/pages/MarketingAnalysis.vue +++ b/src/views/pages/MarketingAnalysis.vue @@ -120,7 +120,7 @@ +
+
+
+
Сегменты аудитории
+ +
+
+
+
Сегмент
+
Доля
+
+
+
+
{{ row.name }}
+
{{ row.shareLabel }}
+
+
+
+
+
+
@@ -314,7 +338,7 @@
@@ -543,6 +567,30 @@ +
+
+
+
Узкие места воронки
+ +
+
+
+
Этап
+
Причина
+
+
+
+
{{ row.stage }}
+
{{ row.reason }}
+
+
+
+
+
+
@@ -585,6 +633,24 @@ /> +
+
+
+
Доли каналов
+ +
+
+ +
+ Нет данных по долям каналов +
+
+
+
+
@@ -1177,15 +1243,39 @@ const marketDemandIndexValue = computed(() => report.value?.sections?.market_ove const marketYoYGrowthValue = computed(() => report.value?.sections?.market_overview?.metrics?.demand_growth_rate_yoy ?? '—'); const marketSeasonalityValue = computed(() => report.value?.sections?.market_overview?.metrics?.seasonality_coefficient_spring ?? '—'); const seasonalityRangeValue = computed(() => report.value?.sections?.market_overview?.metrics?.seasonality_range ?? '—'); +const marketPotentialSubtitle = computed(() => report.value?.sections?.market_overview?.metrics?.online_potential ?? 'высокий онлайн-потенциал'); const audienceTotalValue = computed(() => report.value?.sections?.target_audience?.metrics?.total_audience_size ?? '—'); const audienceLeadingSegmentValue = computed(() => report.value?.sections?.target_audience?.metrics?.leading_segment_share ?? '—'); const audienceIncomeAboveAvgText = computed(() => report.value?.sections?.target_audience?.metrics?.above_average_income_share ?? '—'); -const competitionMarketShareValue = computed(() => report.value?.sections?.competitive_environment?.metrics?.market_share_traffic ?? '—'); -const competitionRankValue = computed(() => report.value?.sections?.competitive_environment?.metrics?.visibility_rank ?? '—'); -const competitionCompetitorShareValue = computed(() => report.value?.sections?.competitive_environment?.metrics?.competitor_share_in_context_ads ?? '—'); -const competitionBenchmarkValue = computed(() => report.value?.sections?.competitive_environment?.metrics?.benchmark_astana ?? '—'); +const competitionMetrics = computed(() => report.value?.sections?.competitive_environment?.metrics ?? {}); +const competitionHasNewMetrics = computed(() => { + const m = competitionMetrics.value; + return Boolean(m.leader_market_share || m.competition_level || m.ad_pressure || m.market_leader_name); +}); +const competitionLeaderSubtitle = computed(() => competitionMetrics.value?.market_leader_name ?? ''); +const competitionMarketShareValue = computed(() => competitionMetrics.value?.leader_market_share ?? competitionMetrics.value?.market_share_traffic ?? '—'); +const competitionRankValue = computed(() => competitionMetrics.value?.visibility_rank ?? competitionMetrics.value?.competition_level ?? '—'); +const competitionCompetitorShareValue = computed(() => competitionMetrics.value?.competitor_share_in_context_ads ?? competitionMetrics.value?.ad_pressure ?? '—'); +const competitionBenchmarkValue = computed(() => competitionMetrics.value?.benchmark_astana ?? '—'); +const competitionRankTitle = computed(() => (competitionHasNewMetrics.value ? 'Уровень конкуренции' : 'Место в видимости')); +const competitionAdPressureTitle = computed(() => (competitionHasNewMetrics.value ? 'Давление в рекламе' : 'Доля конкурентов в контекстной рекламе')); +const competitionRankDescription = computed(() => + competitionHasNewMetrics.value + ? 'Общая оценка насыщенности рынка конкурентами. Помогает понять уровень борьбы за клиента.' + : 'Позиция бренда в поисковой выдаче и рекламных каналах относительно конкурентов. Чем ниже число, тем выше позиция.' +); +const competitionAdPressureDescription = computed(() => + competitionHasNewMetrics.value + ? 'Характер рекламного давления со стороны конкурентов. Показывает, насколько агрессивны их кампании.' + : 'Активность конкурентов в платной рекламе. Показывает уровень конкуренции в контекстной рекламе и необходимость инвестиций в этот канал.' +); +const competitionAdPressureSubtitle = computed(() => { + if (competitionHasNewMetrics.value) return ''; + const benchmark = competitionBenchmarkValue.value; + return benchmark && benchmark !== '—' ? `Бенчмарк (Астана) ${benchmark}` : ''; +}); const behaviorSearchGrowthValue = computed(() => report.value?.sections?.demand_and_behavior?.metrics?.search_volume_growth ?? '—'); const behaviorInteractionsValue = computed(() => report.value?.sections?.demand_and_behavior?.metrics?.interactions_before_purchase ?? '—'); @@ -1323,6 +1413,7 @@ const ageRows = computed(() => { '35-44': { '35-44': 1.0 }, '20-40': { '25-34': 0.5, '35-44': 0.5 }, // Распределяем поровну '45-54': { '45-54': 1.0 }, + '45+': { '45-54': 1.0 }, '40-60': { '45-54': 1.0 }, // Маппим на 45-54 '55+': { '55+': 1.0 }, '60+': { '55+': 1.0 }, @@ -1584,6 +1675,7 @@ const incomeRows = computed(() => { '35-44': { '35-44': 1.0 }, '20-40': { '25-34': 0.5, '35-44': 0.5 }, '45-54': { '45-54': 1.0 }, + '45+': { '45-54': 1.0 }, '40-60': { '45-54': 1.0 }, '55+': { '55+': 1.0 }, '60+': { '55+': 1.0 }, @@ -1650,8 +1742,14 @@ const segmentRows = computed(() => { const segs = report.value?.sections?.target_audience?.segments ?? []; return segs .map((s) => { - const n = Number(String(s.share ?? '').replace(/[^\d.]/g, '')); - return { name: s.name ?? '—', value: Number.isFinite(n) ? n : 0 }; + const shareText = s?.share !== undefined && s?.share !== null ? String(s.share) : ''; + const n = Number(String(shareText).replace(/[^\d.]/g, '')); + const value = Number.isFinite(n) ? n : 0; + return { + name: s?.name ?? '—', + value, + shareLabel: shareText || (value ? `${value}%` : '—') + }; }) .sort((a, b) => b.value - a.value); }); @@ -1698,7 +1796,44 @@ function getGeographyGradient(percentage, index) { } // Competitive: visibility horizontal bar +const competitiveListRows = computed(() => { + const list = report.value?.sections?.competitive_environment?.competitors; + if (!Array.isArray(list)) return []; + + return list + .map((competitor, index) => { + if (!competitor || typeof competitor !== 'object') return null; + const label = competitor.name ? String(competitor.name) : `Конкурент ${index + 1}`; + const visibilityRaw = competitor.visibility_score ?? competitor.visibilityScore ?? 0; + const visibility = typeof visibilityRaw === 'number' ? visibilityRaw : percentToNumber(visibilityRaw); + return { + key: competitor.name ? String(competitor.name) : `competitor_${index + 1}`, + label, + visibility: Number.isFinite(visibility) ? visibility : 0, + strengths: Array.isArray(competitor.key_strengths) + ? competitor.key_strengths + : Array.isArray(competitor.keyStrengths) + ? competitor.keyStrengths + : [], + priceSegment: competitor.price_segment ?? competitor.priceSegment ?? '', + website: competitor.website ?? '' + }; + }) + .filter(Boolean); +}); + const comparisonRows = computed(() => { + if (competitiveListRows.value.length > 0) { + return competitiveListRows.value.map((row) => ({ + key: row.key, + label: row.label, + visibility: row.visibility, + strengths: row.strengths, + priceSegment: row.priceSegment, + website: row.website + })); + } + const bench = report.value?.sections?.competitive_environment?.digital_visibility_benchmarks ?? {}; const strengths = report.value?.sections?.competitive_environment?.competitor_strengths ?? {}; @@ -1731,6 +1866,7 @@ const visibilityBarData = computed(() => { payment: PPT_ORANGE, others: '#FF6B6B' }; + const fallbackColors = [PPT_BLUE, PPT_GREEN, PPT_ORANGE, '#60A5FA', '#A855F7', '#EC4899', '#F59E0B']; return { labels, @@ -1743,7 +1879,7 @@ const visibilityBarData = computed(() => { backgroundColor: (context) => { const { chart, dataIndex } = context; const { ctx, chartArea } = chart; - const base = baseByKey[keys[dataIndex]] ?? PPT_BLUE; + const base = baseByKey[keys[dataIndex]] ?? fallbackColors[dataIndex % fallbackColors.length]; if (!chartArea) return hexToRgba(base, 0.55); return makeVerticalGradient(ctx, chartArea, hexToRgba(base, 0.88), hexToRgba(base, 0.24)); } @@ -1814,14 +1950,25 @@ const recommendationMessages = computed(() => { const bottlenecksDetails = computed(() => report.value?.sections?.bottlenecks?.details ?? { landing: [], lead_management: [], retention: [] }); const competitionStrengthRows = computed(() => { + if (competitiveListRows.value.length > 0) { + return competitiveListRows.value.map((row) => { + const extras = []; + if (row.priceSegment) extras.push(`Сегмент: ${row.priceSegment}`); + if (row.website) extras.push(String(row.website)); + return { + label: row.label, + strengths: [...(row.strengths ?? []), ...extras] + }; + }); + } + const strengths = report.value?.sections?.competitive_environment?.competitor_strengths ?? {}; - const rows = [ + return [ { label: 'Ваш бизнес', strengths: strengths.your_business ?? [] }, { label: 'Конкурент A', strengths: strengths.competitor_a ?? [] }, { label: 'Конкурент B', strengths: strengths.competitor_b ?? [] }, { label: 'Конкурент C', strengths: strengths.competitor_c ?? [] } ]; - return rows; }); // Demand & behavior charts @@ -1830,16 +1977,100 @@ function percentToNumber(p) { return Number.isFinite(n) ? n : 0; } -const queryTypesDonutData = computed(() => { +const CHART_COLORS = [PPT_BLUE, '#7C3AED', PPT_ORANGE, '#34D399', '#60A5FA', '#A855F7', '#F59E0B', '#10B981']; + +function getChartColors(count) { + const safeCount = Math.max(0, Number(count) || 0); + return Array.from({ length: safeCount }, (_, i) => CHART_COLORS[i % CHART_COLORS.length]); +} + +function normalizeLabel(rawKey) { + const raw = String(rawKey ?? '').trim(); + if (!raw) return ''; + if (!raw.includes('_')) return raw; + return raw + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +function normalizeEntries(raw, preferredEntries = [], aliasOrder = [], aliasMap = {}) { + if (!raw || typeof raw !== 'object') return []; + + const hasPreferred = preferredEntries.some(({ key }) => raw[key] !== undefined); + if (hasPreferred) { + return preferredEntries + .map(({ key, label }) => ({ label, value: raw[key] })) + .filter(({ value }) => value !== undefined && value !== null && value !== ''); + } + + const aliasEntries = aliasOrder + .filter((key) => raw[key] !== undefined) + .map((key) => ({ label: aliasMap[key] ?? key, value: raw[key] })); + if (aliasEntries.length > 0) return aliasEntries; + + return Object.entries(raw).map(([key, value]) => ({ label: normalizeLabel(key), value })); +} + +const QUERY_TYPE_KEYS = [ + { key: 'direct_interest_catalog', label: 'Прямой интерес' }, + { key: 'question_based_articles', label: 'Поиск по вопросу' }, + { key: 'competitors_reviews', label: 'Конкуренты' }, + { key: 'problem_solving_hot_promo', label: 'Решение проблемы' } +]; +const QUERY_TYPE_ALIAS_ORDER = ['Коммерческие (купить, цена)', 'Информационные (отзывы)', 'Брендовые', 'Гео-запросы']; +const QUERY_TYPE_ALIAS_MAP = { + 'Коммерческие (купить, цена)': 'Коммерческие (купить, цена)', + 'Информационные (отзывы)': 'Информационные (отзывы)', + 'Брендовые': 'Брендовые', + 'Гео-запросы': 'Гео-запросы' +}; + +const DECISION_FACTOR_KEYS = [ + { key: 'promo_discount', label: 'Акция/скидка' }, + { key: 'review_recommendation', label: 'Отзыв/рекоменд.' }, + { key: 'warranty_quality', label: 'Гарантия/качество' }, + { key: 'convenience_service', label: 'Удобство/сервис' }, + { key: 'availability', label: 'Наличие мест' }, + { key: 'price', label: 'Цена' } +]; +const DECISION_FACTOR_ALIAS_ORDER = ['Цена / Рассрочка', 'Отзывы', 'Локация', 'Сервис', 'Бренд']; +const DECISION_FACTOR_ALIAS_MAP = { + 'Цена / Рассрочка': 'Цена / Рассрочка', + 'Отзывы': 'Отзывы', + 'Локация': 'Локация', + 'Сервис': 'Сервис', + 'Бренд': 'Бренд' +}; + +const REFUSAL_REASON_KEYS = [ + { key: 'high_price', label: 'Высокая цена' }, + { key: 'no_reviews_social_proof', label: 'Нет отзывов' }, + { key: 'choice_difficulty_unclear_offer', label: 'Сложности с выбором' } +]; +const REFUSAL_REASON_ALIAS_ORDER = ['Скрытые условия', 'Плохие отзывы', 'Долгая реакция']; +const REFUSAL_REASON_ALIAS_MAP = { + 'Скрытые условия': 'Скрытые условия', + 'Плохие отзывы': 'Плохие отзывы', + 'Долгая реакция': 'Долгая реакция' +}; + +const queryTypeEntries = computed(() => { const q = report.value?.sections?.demand_and_behavior?.search_query_types ?? {}; - const labels = ['Прямой интерес', 'Поиск по вопросу', 'Конкуренты', 'Решение проблемы']; - const values = [q.direct_interest_catalog, q.question_based_articles, q.competitors_reviews, q.problem_solving_hot_promo].map(percentToNumber); + return normalizeEntries(q, QUERY_TYPE_KEYS, QUERY_TYPE_ALIAS_ORDER, QUERY_TYPE_ALIAS_MAP); +}); + +const queryTypesDonutData = computed(() => { + const entries = queryTypeEntries.value; + const labels = entries.map((entry) => entry.label); + const values = entries.map((entry) => percentToNumber(entry.value)); + const colors = getChartColors(labels.length); return { labels, datasets: [ { data: values, - backgroundColor: [PPT_BLUE, '#7C3AED', PPT_ORANGE, '#34D399'], + backgroundColor: colors, borderColor: '#ffffff', borderWidth: 2 } @@ -1849,34 +2080,34 @@ const queryTypesDonutData = computed(() => { // Метрики для отображения под диаграммой const queryTypesMetrics = computed(() => { - const q = report.value?.sections?.demand_and_behavior?.search_query_types ?? {}; - const labels = ['Прямой интерес', 'Поиск по вопросу', 'Конкуренты', 'Решение проблемы']; - const values = [q.direct_interest_catalog, q.question_based_articles, q.competitors_reviews, q.problem_solving_hot_promo].map(percentToNumber); - const colors = [PPT_BLUE, '#7C3AED', PPT_ORANGE, '#34D399']; + const entries = queryTypeEntries.value; + const colors = getChartColors(entries.length); - return labels - .map((label, index) => ({ - label, - value: values[index] || 0, + return entries + .map((entry, index) => ({ + label: entry.label, + value: percentToNumber(entry.value), color: colors[index] })) .filter((metric) => metric.value > 0); }); const queryTypesTableRows = computed(() => { - const q = report.value?.sections?.demand_and_behavior?.search_query_types ?? {}; - return [ - { label: 'Прямой интерес', value: percentToNumber(q.direct_interest_catalog) }, - { label: 'Поиск по вопросу', value: percentToNumber(q.question_based_articles) }, - { label: 'Конкуренты', value: percentToNumber(q.competitors_reviews) }, - { label: 'Решение проблемы', value: percentToNumber(q.problem_solving_hot_promo) } - ]; + return queryTypeEntries.value.map((entry) => ({ + label: entry.label, + value: percentToNumber(entry.value) + })); +}); + +const decisionFactorEntries = computed(() => { + const f = report.value?.sections?.demand_and_behavior?.decision_factors ?? {}; + return normalizeEntries(f, DECISION_FACTOR_KEYS, DECISION_FACTOR_ALIAS_ORDER, DECISION_FACTOR_ALIAS_MAP); }); const decisionFactorsBarData = computed(() => { - const f = report.value?.sections?.demand_and_behavior?.decision_factors ?? {}; - const labels = ['Акция/скидка', 'Отзыв/рекоменд.', 'Гарантия/качество', 'Удобство/сервис', 'Наличие мест', 'Цена']; - const values = [f.promo_discount, f.review_recommendation, f.warranty_quality, f.convenience_service, f.availability, f.price].map(percentToNumber); + const entries = decisionFactorEntries.value; + const labels = entries.map((entry) => entry.label); + const values = entries.map((entry) => percentToNumber(entry.value)); return { labels, datasets: [ @@ -1896,21 +2127,21 @@ const decisionFactorsBarData = computed(() => { }); const decisionFactorsTableRows = computed(() => { - const f = report.value?.sections?.demand_and_behavior?.decision_factors ?? {}; - return [ - { label: 'Акция/скидка', value: percentToNumber(f.promo_discount) }, - { label: 'Отзыв/рекоменд.', value: percentToNumber(f.review_recommendation) }, - { label: 'Гарантия/качество', value: percentToNumber(f.warranty_quality) }, - { label: 'Удобство/сервис', value: percentToNumber(f.convenience_service) }, - { label: 'Наличие мест', value: percentToNumber(f.availability) }, - { label: 'Цена', value: percentToNumber(f.price) } - ]; + return decisionFactorEntries.value.map((entry) => ({ + label: entry.label, + value: percentToNumber(entry.value) + })); +}); + +const refusalReasonEntries = computed(() => { + const r = report.value?.sections?.demand_and_behavior?.reasons_for_refusal ?? {}; + return normalizeEntries(r, REFUSAL_REASON_KEYS, REFUSAL_REASON_ALIAS_ORDER, REFUSAL_REASON_ALIAS_MAP); }); const refusalReasonsBarData = computed(() => { - const r = report.value?.sections?.demand_and_behavior?.reasons_for_refusal ?? {}; - const labels = ['Высокая цена', 'Нет отзывов', 'Сложности с выбором']; - const values = [r.high_price, r.no_reviews_social_proof, r.choice_difficulty_unclear_offer].map(percentToNumber); + const entries = refusalReasonEntries.value; + const labels = entries.map((entry) => entry.label); + const values = entries.map((entry) => percentToNumber(entry.value)); return { labels, datasets: [ @@ -1930,35 +2161,30 @@ const refusalReasonsBarData = computed(() => { }); const refusalReasonsTableRows = computed(() => { - const r = report.value?.sections?.demand_and_behavior?.reasons_for_refusal ?? {}; - return [ - { label: 'Высокая цена', value: percentToNumber(r.high_price) }, - { label: 'Нет отзывов', value: percentToNumber(r.no_reviews_social_proof) }, - { label: 'Сложности с выбором', value: percentToNumber(r.choice_difficulty_unclear_offer) } - ]; + return refusalReasonEntries.value.map((entry) => ({ + label: entry.label, + value: percentToNumber(entry.value) + })); }); // Acquisition channels charts/tables const channelSharesBarData = computed(() => { - const shares = report.value?.sections?.acquisition_channels?.channel_shares ?? {}; - const items = [ - { key: 'yandex_direct', label: 'Яндекс Директ', value: shares.yandex_direct, color: PPT_ORANGE }, - { key: 'seo', label: 'SEO (сиб.тайт)', value: shares.seo, color: PPT_BLUE }, - { key: 'retargeting', label: 'Ретаргетинг', value: shares.retargeting, color: PPT_GREEN }, - { key: 'vkontakte', label: 'VKontakte', value: shares.vkontakte, color: '#60A5FA' } - ]; + const entries = channelShareEntries.value; + const labels = entries.map((entry) => entry.label || getChannelLabel(entry.label)); + const values = entries.map((entry) => percentToNumber(entry.value)); + const colors = getChartColors(labels.length); return { - labels: items.map((i) => i.label), + labels, datasets: [ { - data: items.map((i) => percentToNumber(i.value)), + data: values, unit: '%', borderRadius: 12, borderSkipped: false, backgroundColor: (context) => { const { chart, dataIndex } = context; const { ctx, chartArea } = chart; - const base = items[dataIndex]?.color ?? PPT_BLUE; + const base = colors[dataIndex] ?? PPT_BLUE; if (!chartArea) return hexToRgba(base, 0.55); return makeVerticalGradient(ctx, chartArea, hexToRgba(base, 0.88), hexToRgba(base, 0.24)); } @@ -1968,13 +2194,45 @@ const channelSharesBarData = computed(() => { }); const channelSharesTableRows = computed(() => { - const shares = report.value?.sections?.acquisition_channels?.channel_shares ?? {}; - return [ - { label: 'Яндекс Директ', value: percentToNumber(shares.yandex_direct) }, - { label: 'SEO (сиб.тайт)', value: percentToNumber(shares.seo) }, - { label: 'Ретаргетинг', value: percentToNumber(shares.retargeting) }, - { label: 'VKontakte', value: percentToNumber(shares.vkontakte) } - ]; + return channelShareEntries.value.map((entry) => ({ + label: entry.label || getChannelLabel(entry.label), + value: percentToNumber(entry.value) + })); +}); + +const FUNNEL_BOTTLENECK_LABELS = { + lead_to_booking: 'Лид → запись', + booking_to_payment: 'Запись → оплата', + lead_to_payment_total: 'Лид → оплата' +}; + +const funnelBottleneckRows = computed(() => { + const bottlenecks = report.value?.sections?.customer_journey?.funnel_bottlenecks ?? {}; + if (!bottlenecks || typeof bottlenecks !== 'object') return []; + const orderedKeys = Object.keys(FUNNEL_BOTTLENECK_LABELS); + const rows = []; + + orderedKeys.forEach((key) => { + if (bottlenecks[key]) { + rows.push({ + key, + stage: FUNNEL_BOTTLENECK_LABELS[key], + reason: String(bottlenecks[key]) + }); + } + }); + + Object.entries(bottlenecks).forEach(([key, reason]) => { + if (orderedKeys.includes(key)) return; + if (!reason) return; + rows.push({ + key, + stage: normalizeLabel(key), + reason: String(reason) + }); + }); + + return rows; }); const funnelBarData = computed(() => { @@ -2033,30 +2291,61 @@ function extractCostNumber(costStr) { return Number.isFinite(num) ? num : 0; } +const CHANNEL_LABELS = { + yandex: 'Яндекс', + yandex_direct: 'Яндекс Директ', + seo: 'SEO', + retargeting: 'Ретаргет', + vkontakte: 'VKontakte', + youtube: 'YouTube', + instagram: 'Instagram', + tiktok: 'TikTok', + kaspi: 'Kaspi', + google_search: 'Google Поиск', + '2gis': '2GIS', + google_ads: 'Google Ads', + facebook: 'Facebook' +}; + +function getChannelLabel(key) { + const raw = String(key ?? ''); + if (CHANNEL_LABELS[raw]) return CHANNEL_LABELS[raw]; + if (raw.includes(' ')) return raw; + return raw + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +const CHANNEL_SHARE_KEYS = [ + { key: 'yandex_direct', label: 'Яндекс Директ' }, + { key: 'seo', label: 'SEO (сиб.тайт)' }, + { key: 'retargeting', label: 'Ретаргетинг' }, + { key: 'vkontakte', label: 'VKontakte' } +]; + +const channelShareEntries = computed(() => { + const shares = report.value?.sections?.acquisition_channels?.channel_shares ?? {}; + if (!shares || typeof shares !== 'object') return []; + + const hasPreferred = CHANNEL_SHARE_KEYS.some(({ key }) => shares[key] !== undefined); + if (hasPreferred) { + const preferred = CHANNEL_SHARE_KEYS + .map(({ key, label }) => ({ label, value: shares[key] })) + .filter(({ value }) => value !== undefined && value !== null && value !== ''); + const preferredKeys = new Set(CHANNEL_SHARE_KEYS.map(({ key }) => key)); + const extras = Object.entries(shares) + .filter(([key]) => !preferredKeys.has(key)) + .map(([key, value]) => ({ label: getChannelLabel(key), value })); + return [...preferred, ...extras]; + } + + return Object.entries(shares).map(([key, value]) => ({ label: getChannelLabel(key), value })); +}); + // График эффективности каналов (стоимость привлечения) const channelEfficiencyBarData = computed(() => { const eff = report.value?.sections?.acquisition_channels?.channel_efficiency ?? {}; - - // Маппинг ключей каналов на читаемые названия - const channelLabels = { - 'yandex': 'Яндекс', - 'yandex_direct': 'Яндекс Директ', - 'seo': 'SEO', - 'retargeting': 'Ретаргет', - 'vkontakte': 'VKontakte', - 'youtube': 'YouTube', - 'instagram': 'Instagram', - 'tiktok': 'TikTok', - 'kaspi': 'Kaspi', - 'google_search': 'Google Поиск', - '2gis': '2GIS', - 'google_ads': 'Google Ads', - 'facebook': 'Facebook' - }; - - function getChannelLabel(key) { - return channelLabels[key] || key.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); - } // Цвета для разных каналов const channelColors = [ @@ -2140,27 +2429,6 @@ const channelEfficiencyBarOptions = computed(() => { const channelEfficiencyRows = computed(() => { const eff = report.value?.sections?.acquisition_channels?.channel_efficiency ?? {}; - - // Маппинг ключей каналов на читаемые названия - const channelLabels = { - 'yandex': 'Яндекс', - 'yandex_direct': 'Яндекс Директ', - 'seo': 'SEO', - 'retargeting': 'Ретаргет', - 'vkontakte': 'VKontakte', - 'youtube': 'YouTube', - 'instagram': 'Instagram', - 'tiktok': 'TikTok', - 'kaspi': 'Kaspi', - 'google_search': 'Google Поиск', - '2gis': '2GIS', - 'google_ads': 'Google Ads', - 'facebook': 'Facebook' - }; - - function getChannelLabel(key) { - return channelLabels[key] || key.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); - } function statusClass(status) { const s = String(status || '').toLowerCase();