mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-18 12:05:05 +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,11 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
<slot name="header">
|
||||||
|
<div class="card-header--title-area">
|
||||||
<h5>{{ header }}</h5>
|
<h5>{{ header }}</h5>
|
||||||
<span class="live">
|
<span class="live">
|
||||||
<span class="ellipse" /><span>{{ $t('OVERVIEW_REPORTS.LIVE') }}</span>
|
<span class="ellipse" /><span>{{
|
||||||
|
$t('OVERVIEW_REPORTS.LIVE')
|
||||||
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-header--control-area">
|
||||||
|
<slot name="control" />
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
<div v-if="!isLoading" class="card-body row">
|
<div v-if="!isLoading" class="card-body row">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
@@ -42,12 +51,31 @@ export default {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.card {
|
.card {
|
||||||
margin: var(--space-small) !important;
|
margin: var(--space-small) !important;
|
||||||
|
|
||||||
|
.card-header--control-area {
|
||||||
|
opacity: 0.2;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.card-header--control-area {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card-header {
|
.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;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: var(--space-medium);
|
|
||||||
|
|
||||||
h5 {
|
h5 {
|
||||||
margin-bottom: var(--zero);
|
margin-bottom: var(--zero);
|
||||||
@@ -73,6 +101,16 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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