diff --git a/app/controllers/api/v1/accounts/landing_hosts_controller.rb b/app/controllers/api/v1/accounts/landing_hosts_controller.rb index c1c23b6e7..5c7343cec 100644 --- a/app/controllers/api/v1/accounts/landing_hosts_controller.rb +++ b/app/controllers/api/v1/accounts/landing_hosts_controller.rb @@ -39,6 +39,6 @@ class Api::V1::Accounts::LandingHostsController < Api::V1::Accounts::BaseControl end def landing_host_params - params.require(:landing_host).permit(:hostname, :unit_code, :active) + params.require(:landing_host).permit(:hostname, :unit_code, :active, :auto_label) end end diff --git a/app/controllers/api/v1/accounts/lead_click_stats_controller.rb b/app/controllers/api/v1/accounts/lead_click_stats_controller.rb index 49077e41b..4a4366358 100644 --- a/app/controllers/api/v1/accounts/lead_click_stats_controller.rb +++ b/app/controllers/api/v1/accounts/lead_click_stats_controller.rb @@ -1,37 +1,83 @@ class Api::V1::Accounts::LeadClickStatsController < Api::V1::Accounts::BaseController - def index + def show clicks = account_clicks - total = clicks.count - convs = clicks.where.not(conversation_id: nil).count + render json: stats_payload(clicks) + end - render json: { - total_clicks: total, - total_conversions: convs, - conversion_rate: total.positive? ? (convs.to_f / total * 100).round(1) : 0, + private + + def account_clicks + scope = LeadClick.joins(:inbox).where(inboxes: { account_id: current_account.id }) + scope = scope.where(inbox_id: params[:inbox_id]) if params[:inbox_id].present? + apply_period_filter(scope) + end + + def apply_period_filter(scope) + return scope unless params[:period_start].present? && params[:period_end].present? + + start_at = Time.zone.parse(params[:period_start].to_s)&.beginning_of_day + end_at = Time.zone.parse(params[:period_end].to_s)&.end_of_day + return scope unless start_at && end_at + + scope.where(created_at: start_at..end_at) + end + + def stats_payload(clicks) + total_clicks = clicks.count + total_conversions = clicks.where.not(conversation_id: nil).count + total_non_converted = total_clicks - total_conversions + + { + total_clicks: total_clicks, + total_conversions: total_conversions, + total_non_converted: total_non_converted, + drop_off_rate: percentage(total_non_converted, total_clicks), + conversion_rate: percentage(total_conversions, total_clicks), + unique_click_ids: clicks.where.not(click_id: [nil, '']).distinct.count(:click_id), + unique_converted_contacts: clicks.where.not(contact_id: nil).distinct.count(:contact_id), + daily: daily_breakdown(clicks), by_source: group_by(clicks, :source), by_campaign: group_by(clicks, :campanha), by_hostname: group_by(clicks, :hostname) } end - private + def percentage(part, total) + return 0 unless total.positive? - def account_clicks - LeadClick.joins(:inbox).where(inboxes: { account_id: current_account.id }) + (part.to_f / total * 100).round(1) end def group_by(clicks, column) - clicks - .group(column) - .select("#{column}, COUNT(*) AS clicks, COUNT(conversation_id) AS conversions") - .map do |r| - { - label: r.public_send(column).presence || '(sem nome)', - clicks: r.clicks, - conversions: r.conversions, - rate: r.clicks.positive? ? (r.conversions.to_f / r.clicks * 100).round(1) : 0 - } - end - .sort_by { |r| -r[:clicks] } + rows = clicks + .group(column) + .select("#{column}, COUNT(*) AS clicks, COUNT(conversation_id) AS conversions") + + grouped_rows = rows.map do |row| + { + label: row.public_send(column).presence || '(sem nome)', + clicks: row.clicks, + conversions: row.conversions, + rate: row.clicks.positive? ? (row.conversions.to_f / row.clicks * 100).round(1) : 0 + } + end + + grouped_rows.sort_by { |row| -row[:clicks] } + end + + def daily_breakdown(clicks) + rows = clicks + .group('DATE(created_at)') + .select('DATE(created_at) AS day, COUNT(*) AS clicks, COUNT(conversation_id) AS conversions') + .order('day ASC') + + rows.map do |row| + { + day: row.day.to_s, + clicks: row.clicks.to_i, + conversions: row.conversions.to_i, + non_converted: row.clicks.to_i - row.conversions.to_i + } + end end end diff --git a/app/controllers/public/landing_pages_controller.rb b/app/controllers/public/landing_pages_controller.rb new file mode 100644 index 000000000..32e21a338 --- /dev/null +++ b/app/controllers/public/landing_pages_controller.rb @@ -0,0 +1,5 @@ +class Public::LandingPagesController < PublicController + layout false + + def show; end +end diff --git a/app/javascript/dashboard/i18n/locale/en/captain.json b/app/javascript/dashboard/i18n/locale/en/captain.json index f0bdf1ded..a90081d19 100644 --- a/app/javascript/dashboard/i18n/locale/en/captain.json +++ b/app/javascript/dashboard/i18n/locale/en/captain.json @@ -403,6 +403,11 @@ "TOTAL_CLICKS": "Total Clicks", "TOTAL_CONVERSIONS": "Conversions (WhatsApp)", "CONVERSION_RATE": "Conversion Rate", + "TOTAL_DROPOFF": "Drop-off (No conversation)", + "DROPOFF_RATE": "Drop-off Rate", + "UNIQUE_CONTACTS": "Unique contacts converted", + "FUNNEL_TITLE": "Landing page funnel", + "DAILY_TREND": "Daily click vs conversion trend", "BY_SOURCE": "Clicks by Source", "BY_CAMPAIGN": "Clicks by Campaign", "BY_HOSTNAME": "Clicks by Landing Page", @@ -416,9 +421,14 @@ "LABEL": "Period:", "START": "Start Date", "END": "End Date", + "TODAY": "Today", + "YESTERDAY": "Yesterday", "LAST_7_DAYS": "Last 7 days", + "LAST_30_DAYS": "Last 30 days", + "THIS_WEEK": "This week", "LAST_WEEK": "Last Week", "CURRENT_MONTH": "Current Month", + "LAST_MONTH": "Last Month", "CUSTOM": "Custom", "SEPARATOR": "-" }, diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/captain.json b/app/javascript/dashboard/i18n/locale/pt_BR/captain.json index 89f6c6f60..f0a51be2c 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/captain.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/captain.json @@ -404,6 +404,11 @@ "TOTAL_CLICKS": "Total de Cliques", "TOTAL_CONVERSIONS": "Conversões (WhatsApp)", "CONVERSION_RATE": "Taxa de Conversão", + "TOTAL_DROPOFF": "Perdas (sem conversa)", + "DROPOFF_RATE": "Taxa de Perda", + "UNIQUE_CONTACTS": "Contatos únicos convertidos", + "FUNNEL_TITLE": "Funil da landing page", + "DAILY_TREND": "Tendência diária de cliques vs conversões", "BY_SOURCE": "Cliques por Origem", "BY_CAMPAIGN": "Cliques por Campanha", "BY_HOSTNAME": "Cliques por Landing Page", @@ -417,9 +422,14 @@ "LABEL": "Período:", "START": "Data Início", "END": "Data Fim", + "TODAY": "Hoje", + "YESTERDAY": "Ontem", "LAST_7_DAYS": "Últimos 7 dias", + "LAST_30_DAYS": "Últimos 30 dias", + "THIS_WEEK": "Esta semana", "LAST_WEEK": "Semana Passada", "CURRENT_MONTH": "Mês Atual", + "LAST_MONTH": "Mês Passado", "CUSTOM": "Personalizado", "SEPARATOR": "-" }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue index 48c3f119c..59a6f03ea 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue @@ -18,7 +18,7 @@ const assistants = useMapGetter('captainAssistants/getRecords'); const activeTab = ref('dashboard'); const selectedInboxId = ref(null); -const selectedPeriod = ref('last_week'); +const selectedPeriod = ref('last_7_days'); const customStartDate = ref(''); const customEndDate = ref(''); const expandedInsights = ref({}); @@ -33,32 +33,32 @@ const tabs = [ const lpStats = ref(null); const lpLoading = ref(false); -const fetchLpStats = async () => { - const user = store.getters['auth/getCurrentUser']; - const accountId = - user?.account_id || window.location.pathname.match(/accounts\/(\d+)/)?.[1]; - if (!accountId) return; - lpLoading.value = true; - try { - const { data } = await axios.get( - `/api/v1/accounts/${accountId}/lead_click_stats` - ); - lpStats.value = data; - } catch { - // silent - } finally { - lpLoading.value = false; - } -}; - -const getPeriodDates = period => { +function getPeriodDates(period) { const end = new Date(); const start = new Date(); + const now = new Date(); switch (period) { + case 'today': + start.setFullYear(now.getFullYear(), now.getMonth(), now.getDate()); + end.setFullYear(now.getFullYear(), now.getMonth(), now.getDate()); + break; + case 'yesterday': + start.setFullYear(now.getFullYear(), now.getMonth(), now.getDate() - 1); + end.setFullYear(now.getFullYear(), now.getMonth(), now.getDate() - 1); + break; case 'last_7_days': start.setDate(end.getDate() - 7); break; + case 'last_30_days': + start.setDate(end.getDate() - 30); + break; + case 'this_week': { + const day = now.getDay(); + const diffToMonday = day === 0 ? 6 : day - 1; + start.setDate(now.getDate() - diffToMonday); + break; + } case 'last_week': { const day = end.getDay(); const diffToLastSunday = day === 0 ? 7 : day; @@ -69,6 +69,10 @@ const getPeriodDates = period => { case 'current_month': start.setDate(1); break; + case 'last_month': + start.setMonth(start.getMonth() - 1, 1); + end.setDate(0); + break; case 'custom': return { period_start: customStartDate.value, @@ -82,6 +86,30 @@ const getPeriodDates = period => { period_start: start.toISOString().split('T')[0], period_end: end.toISOString().split('T')[0], }; +} + +const fetchLpStats = async () => { + const user = store.getters['auth/getCurrentUser']; + const accountId = + user?.account_id || window.location.pathname.match(/accounts\/(\d+)/)?.[1]; + if (!accountId) return; + const { period_start, period_end } = getPeriodDates(selectedPeriod.value); + const params = { + ...(selectedInboxId.value && { inbox_id: selectedInboxId.value }), + ...(period_start && period_end && { period_start, period_end }), + }; + lpLoading.value = true; + try { + const { data } = await axios.get( + `/api/v1/accounts/${accountId}/lead_click_stats`, + { params } + ); + lpStats.value = data; + } catch { + // silent + } finally { + lpLoading.value = false; + } }; let pollInterval = null; @@ -113,6 +141,17 @@ watch(hasProcessingInsights, newVal => { else stopPolling(); }); +watch(activeTab, async tab => { + if (tab === 'landing_pages') await fetchLpStats(); +}); + +watch([customStartDate, customEndDate], async () => { + if (selectedPeriod.value !== 'custom') return; + if (activeTab.value !== 'landing_pages') return; + if (!customStartDate.value || !customEndDate.value) return; + await fetchLpStats(); +}); + // Auto-expand first done insight when loaded watch( insights, @@ -142,10 +181,12 @@ const onFilterChange = async event => { await store.dispatch('captainReports/fetchInsights', { inbox_id: selectedInboxId.value, }); + if (activeTab.value === 'landing_pages') await fetchLpStats(); }; -const onPeriodChange = event => { +const onPeriodChange = async event => { selectedPeriod.value = event.target.value; + if (activeTab.value === 'landing_pages') await fetchLpStats(); }; const onGenerateInsight = async () => { @@ -254,6 +295,11 @@ const lpMaxClicks = computed(() => { return Math.max(...all.map(r => r.clicks), 1); }); +const lpDailyMax = computed(() => { + if (!lpStats.value?.daily?.length) return 1; + return Math.max(...lpStats.value.daily.map(r => r.clicks), 1); +}); + // ── FAQ Quick-Add ── const quickAddFaq = reactive({ open: false, @@ -485,15 +531,30 @@ const maxHandoffCount = computed(() => :value="selectedPeriod" @change="onPeriodChange" > + + + + + @@ -1457,7 +1518,7 @@ const maxHandoffCount = computed(() =>
{{ lpStats.total_clicks?.toLocaleString() ?? 0 }} @@ -1482,6 +1543,126 @@ const maxHandoffCount = computed(() => {{ t('CAPTAIN_REPORTS.LP.CONVERSION_RATE') }}
+ {{ lpStats.total_non_converted?.toLocaleString() ?? 0 }} +
++ {{ t('CAPTAIN_REPORTS.LP.TOTAL_DROPOFF') }} +
++ {{ lpStats.drop_off_rate ?? 0 }}{{ '%' }} +
++ {{ t('CAPTAIN_REPORTS.LP.DROPOFF_RATE') }} +
++ {{ lpStats.unique_converted_contacts?.toLocaleString() ?? 0 }} +
++ {{ t('CAPTAIN_REPORTS.LP.UNIQUE_CONTACTS') }} +
++ {{ t('CAPTAIN_REPORTS.LP.FUNNEL_TITLE') }} +
++ {{ t('CAPTAIN_REPORTS.LP.DAILY_TREND') }} +
+