mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-04 04:57:51 +00:00 
			
		
		
		
	[CW-53] feat: allow downloading heatmap report (#6683)
* feat: add control header slot * feat: add download API call * feat: add conversation traffic template * feat: allow downloading heatmap content * feat: wire up download * fix: grid layout for mobile * chore: revert formatting * revert: en.yml file * feat: add conversation traffic text * feat: disable rule for map block * test: conversation traffic * fix: timezone offset * feat: download report in UTC * feat: add UTC warning * chore: revert formatting * feat: add traffic text * chore: fix whitespace change
This commit is contained in:
		@@ -32,6 +32,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
 | 
				
			|||||||
    generate_csv('teams_report', 'api/v2/accounts/reports/teams')
 | 
					    generate_csv('teams_report', 'api/v2/accounts/reports/teams')
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def conversation_traffic
 | 
				
			||||||
 | 
					    @report_data = generate_conversations_heatmap_report
 | 
				
			||||||
 | 
					    generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def conversations
 | 
					  def conversations
 | 
				
			||||||
    return head :unprocessable_entity if params[:type].blank?
 | 
					    return head :unprocessable_entity if params[:type].blank?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,6 +27,27 @@ module Api::V2::Accounts::ReportsHelper
 | 
				
			|||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def generate_conversations_heatmap_report
 | 
				
			||||||
 | 
					    report_params = {
 | 
				
			||||||
 | 
					      type: :account,
 | 
				
			||||||
 | 
					      group_by: 'hour',
 | 
				
			||||||
 | 
					      since: params[:since],
 | 
				
			||||||
 | 
					      until: params[:until],
 | 
				
			||||||
 | 
					      metric: 'conversations_count',
 | 
				
			||||||
 | 
					      business_hours: false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    data = V2::ReportBuilder.new(Current.account, report_params).build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # data format is { timestamp: 1231242342, value: 3}
 | 
				
			||||||
 | 
					    # we need to convert it to { date: "2020-01-01", hour: 12, value: 3}
 | 
				
			||||||
 | 
					    #
 | 
				
			||||||
 | 
					    # the generated report is **always** in UTC timezone
 | 
				
			||||||
 | 
					    data.map do |d|
 | 
				
			||||||
 | 
					      date = Time.zone.at(d[:timestamp]).to_s
 | 
				
			||||||
 | 
					      [date, d[:value]]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def generate_report(report_params)
 | 
					  def generate_report(report_params)
 | 
				
			||||||
    V2::ReportBuilder.new(
 | 
					    V2::ReportBuilder.new(
 | 
				
			||||||
      Current.account,
 | 
					      Current.account,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -59,6 +59,12 @@ class ReportsAPI extends ApiClient {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getConversationTrafficCSV({ from: since, to: until }) {
 | 
				
			||||||
 | 
					    return axios.get(`${this.url}/conversation_traffic`, {
 | 
				
			||||||
 | 
					      params: { since, until },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getLabelReports({ from: since, to: until, businessHours }) {
 | 
					  getLabelReports({ from: since, to: until, businessHours }) {
 | 
				
			||||||
    return axios.get(`${this.url}/labels`, {
 | 
					    return axios.get(`${this.url}/labels`, {
 | 
				
			||||||
      params: { since, until, business_hours: businessHours },
 | 
					      params: { since, until, business_hours: businessHours },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -40,6 +40,17 @@
 | 
				
			|||||||
      <metric-card
 | 
					      <metric-card
 | 
				
			||||||
        :header="this.$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')"
 | 
					        :header="this.$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
 | 
					        <template #control>
 | 
				
			||||||
 | 
					          <woot-button
 | 
				
			||||||
 | 
					            icon="arrow-download"
 | 
				
			||||||
 | 
					            size="small"
 | 
				
			||||||
 | 
					            variant="smooth"
 | 
				
			||||||
 | 
					            color-scheme="secondary"
 | 
				
			||||||
 | 
					            @click="downloadHeatmapData"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Download Report
 | 
				
			||||||
 | 
					          </woot-button>
 | 
				
			||||||
 | 
					        </template>
 | 
				
			||||||
        <report-heatmap
 | 
					        <report-heatmap
 | 
				
			||||||
          :heat-data="accountConversationHeatmap"
 | 
					          :heat-data="accountConversationHeatmap"
 | 
				
			||||||
          :is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
 | 
					          :is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
 | 
				
			||||||
@@ -129,6 +140,15 @@ export default {
 | 
				
			|||||||
      this.fetchAgentConversationMetric();
 | 
					      this.fetchAgentConversationMetric();
 | 
				
			||||||
      this.fetchHeatmapData();
 | 
					      this.fetchHeatmapData();
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    downloadHeatmapData() {
 | 
				
			||||||
 | 
					      let to = endOfDay(new Date());
 | 
				
			||||||
 | 
					      let from = startOfDay(subDays(to, 6));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.$store.dispatch('downloadAccountConversationHeatmap', {
 | 
				
			||||||
 | 
					        from: getUnixTime(from),
 | 
				
			||||||
 | 
					        to: getUnixTime(to),
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    fetchHeatmapData() {
 | 
					    fetchHeatmapData() {
 | 
				
			||||||
      if (this.uiFlags.isFetchingAccountConversationsHeatmap) {
 | 
					      if (this.uiFlags.isFetchingAccountConversationsHeatmap) {
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,19 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="card">
 | 
					  <div class="card">
 | 
				
			||||||
    <div class="card-header">
 | 
					    <div class="card-header">
 | 
				
			||||||
      <h5>{{ header }}</h5>
 | 
					      <slot name="header">
 | 
				
			||||||
      <span class="live">
 | 
					        <div class="card-header--title-area">
 | 
				
			||||||
        <span class="ellipse" /><span>{{ $t('OVERVIEW_REPORTS.LIVE') }}</span>
 | 
					          <h5>{{ header }}</h5>
 | 
				
			||||||
      </span>
 | 
					          <span class="live">
 | 
				
			||||||
 | 
					            <span class="ellipse" /><span>{{
 | 
				
			||||||
 | 
					              $t('OVERVIEW_REPORTS.LIVE')
 | 
				
			||||||
 | 
					            }}</span>
 | 
				
			||||||
 | 
					          </span>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="card-header--control-area">
 | 
				
			||||||
 | 
					          <slot name="control" />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </slot>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <div v-if="!isLoading" class="card-body row">
 | 
					    <div v-if="!isLoading" class="card-body row">
 | 
				
			||||||
      <slot />
 | 
					      <slot />
 | 
				
			||||||
@@ -42,37 +51,66 @@ export default {
 | 
				
			|||||||
<style lang="scss" scoped>
 | 
					<style lang="scss" scoped>
 | 
				
			||||||
.card {
 | 
					.card {
 | 
				
			||||||
  margin: var(--space-small) !important;
 | 
					  margin: var(--space-small) !important;
 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.card-header {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  flex-direction: row;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  margin-bottom: var(--space-medium);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  h5 {
 | 
					  .card-header--control-area {
 | 
				
			||||||
    margin-bottom: var(--zero);
 | 
					    opacity: 0.2;
 | 
				
			||||||
 | 
					    transition: opacity 0.2s ease-in-out;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .live {
 | 
					  &:hover {
 | 
				
			||||||
    display: flex;
 | 
					    .card-header--control-area {
 | 
				
			||||||
    flex-direction: row;
 | 
					      opacity: 1;
 | 
				
			||||||
    align-items: center;
 | 
					 | 
				
			||||||
    padding-right: var(--space-small);
 | 
					 | 
				
			||||||
    padding-left: var(--space-small);
 | 
					 | 
				
			||||||
    margin: var(--space-smaller);
 | 
					 | 
				
			||||||
    background: rgba(37, 211, 102, 0.1);
 | 
					 | 
				
			||||||
    color: var(--g-400);
 | 
					 | 
				
			||||||
    font-size: var(--font-size-mini);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .ellipse {
 | 
					 | 
				
			||||||
      background-color: var(--g-400);
 | 
					 | 
				
			||||||
      height: var(--space-smaller);
 | 
					 | 
				
			||||||
      width: var(--space-smaller);
 | 
					 | 
				
			||||||
      border-radius: var(--border-radius-rounded);
 | 
					 | 
				
			||||||
      margin-right: var(--space-smaller);
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.card-header {
 | 
				
			||||||
 | 
					  display: grid;
 | 
				
			||||||
 | 
					  grid-template-columns: repeat(auto-fit, minmax(max-content, 50%));
 | 
				
			||||||
 | 
					  gap: var(--space-small) 0px;
 | 
				
			||||||
 | 
					  flex-grow: 1;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  margin-bottom: var(--space-medium);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .card-header--title-area {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    h5 {
 | 
				
			||||||
 | 
					      margin-bottom: var(--zero);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .live {
 | 
				
			||||||
 | 
					      display: flex;
 | 
				
			||||||
 | 
					      flex-direction: row;
 | 
				
			||||||
 | 
					      align-items: center;
 | 
				
			||||||
 | 
					      padding-right: var(--space-small);
 | 
				
			||||||
 | 
					      padding-left: var(--space-small);
 | 
				
			||||||
 | 
					      margin: var(--space-smaller);
 | 
				
			||||||
 | 
					      background: rgba(37, 211, 102, 0.1);
 | 
				
			||||||
 | 
					      color: var(--g-400);
 | 
				
			||||||
 | 
					      font-size: var(--font-size-mini);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      .ellipse {
 | 
				
			||||||
 | 
					        background-color: var(--g-400);
 | 
				
			||||||
 | 
					        height: var(--space-smaller);
 | 
				
			||||||
 | 
					        width: var(--space-smaller);
 | 
				
			||||||
 | 
					        border-radius: var(--border-radius-rounded);
 | 
				
			||||||
 | 
					        margin-right: var(--space-smaller);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .card-header--control-area {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    justify-content: end;
 | 
				
			||||||
 | 
					    gap: var(--space-small);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.card-body {
 | 
					.card-body {
 | 
				
			||||||
  .metric-content {
 | 
					  .metric-content {
 | 
				
			||||||
    padding-bottom: var(--space-small);
 | 
					    padding-bottom: var(--space-small);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,7 @@
 | 
				
			|||||||
/* eslint no-console: 0 */
 | 
					/* eslint no-console: 0 */
 | 
				
			||||||
import * as types from '../mutation-types';
 | 
					import * as types from '../mutation-types';
 | 
				
			||||||
import Report from '../../api/reports';
 | 
					import Report from '../../api/reports';
 | 
				
			||||||
import { downloadCsvFile } from '../../helper/downloadHelper';
 | 
					import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
 | 
				
			||||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
 | 
					import AnalyticsHelper from '../../helper/AnalyticsHelper';
 | 
				
			||||||
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
 | 
					import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
@@ -179,6 +179,26 @@ export const actions = {
 | 
				
			|||||||
        console.error(error);
 | 
					        console.error(error);
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  downloadAccountConversationHeatmap(_, reportObj) {
 | 
				
			||||||
 | 
					    Report.getConversationTrafficCSV(reportObj)
 | 
				
			||||||
 | 
					      .then(response => {
 | 
				
			||||||
 | 
					        downloadCsvFile(
 | 
				
			||||||
 | 
					          generateFileName({
 | 
				
			||||||
 | 
					            type: 'Conversation traffic',
 | 
				
			||||||
 | 
					            to: reportObj.to,
 | 
				
			||||||
 | 
					          }),
 | 
				
			||||||
 | 
					          response.data
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
 | 
				
			||||||
 | 
					          reportType: 'conversation_heatmap',
 | 
				
			||||||
 | 
					          businessHours: false,
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      .catch(error => {
 | 
				
			||||||
 | 
					        console.error(error);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mutations = {
 | 
					const mutations = {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										12
									
								
								app/views/api/v2/accounts/reports/conversation_traffic.erb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/views/api/v2/accounts/reports/conversation_traffic.erb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					<% headers = [
 | 
				
			||||||
 | 
					    I18n.t('reports.conversation_traffic_csv.date'),
 | 
				
			||||||
 | 
					    I18n.t('reports.conversation_traffic_csv.conversations_count'),
 | 
				
			||||||
 | 
					  ]
 | 
				
			||||||
 | 
					%>
 | 
				
			||||||
 | 
					<%= CSVSafe.generate_line headers -%>
 | 
				
			||||||
 | 
					<% @report_data.each do |row| %>
 | 
				
			||||||
 | 
					<%= CSVSafe.generate_line row -%>
 | 
				
			||||||
 | 
					<% end %>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
 | 
				
			||||||
 | 
					<%= CSVSafe.generate_line [I18n.t('reports.utc_warning')] %>
 | 
				
			||||||
@@ -73,6 +73,7 @@ en:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  reports:
 | 
					  reports:
 | 
				
			||||||
    period: Reporting period %{since} to %{until}
 | 
					    period: Reporting period %{since} to %{until}
 | 
				
			||||||
 | 
					    utc_warning: The report generated is in UTC timezone
 | 
				
			||||||
    agent_csv:
 | 
					    agent_csv:
 | 
				
			||||||
      agent_name: Agent name
 | 
					      agent_name: Agent name
 | 
				
			||||||
      conversations_count: Conversations count
 | 
					      conversations_count: Conversations count
 | 
				
			||||||
@@ -94,6 +95,9 @@ en:
 | 
				
			|||||||
      conversations_count: Conversations count
 | 
					      conversations_count: Conversations count
 | 
				
			||||||
      avg_first_response_time: Avg first response time (Minutes)
 | 
					      avg_first_response_time: Avg first response time (Minutes)
 | 
				
			||||||
      avg_resolution_time: Avg resolution time (Minutes)
 | 
					      avg_resolution_time: Avg resolution time (Minutes)
 | 
				
			||||||
 | 
					    conversation_traffic_csv:
 | 
				
			||||||
 | 
					      date: Date and time
 | 
				
			||||||
 | 
					      conversations_count: No. of conversations
 | 
				
			||||||
    default_group_by: day
 | 
					    default_group_by: day
 | 
				
			||||||
    csat:
 | 
					    csat:
 | 
				
			||||||
      headers:
 | 
					      headers:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -259,6 +259,7 @@ Rails.application.routes.draw do
 | 
				
			|||||||
            get :labels
 | 
					            get :labels
 | 
				
			||||||
            get :teams
 | 
					            get :teams
 | 
				
			||||||
            get :conversations
 | 
					            get :conversations
 | 
				
			||||||
 | 
					            get :conversation_traffic
 | 
				
			||||||
          end
 | 
					          end
 | 
				
			||||||
        end
 | 
					        end
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -363,4 +363,39 @@ RSpec.describe 'Reports API', type: :request do
 | 
				
			|||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe 'GET /api/v2/accounts/:account_id/reports/conversation_traffic' do
 | 
				
			||||||
 | 
					    context 'when it is an unauthenticated user' do
 | 
				
			||||||
 | 
					      it 'returns unauthorized' do
 | 
				
			||||||
 | 
					        get "/api/v2/accounts/#{account.id}/reports/conversation_traffic.csv"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(response).to have_http_status(:unauthorized)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'when it is an authenticated user' do
 | 
				
			||||||
 | 
					      let(:params) do
 | 
				
			||||||
 | 
					        super().merge(
 | 
				
			||||||
 | 
					          since: 7.days.ago.to_i.to_s,
 | 
				
			||||||
 | 
					          until: date_timestamp.to_s
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'returns unauthorized' do
 | 
				
			||||||
 | 
					        get "/api/v2/accounts/#{account.id}/reports/conversation_traffic.csv",
 | 
				
			||||||
 | 
					            params: params,
 | 
				
			||||||
 | 
					            headers: agent.create_new_auth_token
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(response).to have_http_status(:unauthorized)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'returns values' do
 | 
				
			||||||
 | 
					        get "/api/v2/accounts/#{account.id}/reports/conversation_traffic.csv",
 | 
				
			||||||
 | 
					            params: params,
 | 
				
			||||||
 | 
					            headers: admin.create_new_auth_token
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(response).to have_http_status(:success)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user