feat(v4): Update the report pages to show aggregate values (#10766)

This PR updates the report pages for agents, inboxes, and teams by
replacing charts with aggregate values (under a feature flag). Users can
click on any item to view more details if needed. Most users seem to
prefer aggregate values, so this change will likely stay.

The PR also includes a few fixes:

- The summary reports now use the same logic for both the front-end and
CSV exports.
- Fixed an issue where a single quote was being added to values with
hyphens in CSV files. Now, ‘n/a’ is used when no value is available.
- Fixed a bug where the average value was calculated incorrectly when
multiple accounts were present.

These changes should make reports easier to use and more consistent.

### Agents:

<img width="1438" alt="Screenshot 2025-01-26 at 10 47 18 AM"
src="https://github.com/user-attachments/assets/bf2fcebc-6207-4701-9703-5c2110b7b8a0"
/>

### Inboxes
<img width="1438" alt="Screenshot 2025-01-26 at 10 47 10 AM"
src="https://github.com/user-attachments/assets/b83e1cf2-fd14-4e8e-8dcd-9033404a9f22"
/>


### Teams: 
<img width="1436" alt="Screenshot 2025-01-26 at 10 47 01 AM"
src="https://github.com/user-attachments/assets/96b1ce07-f557-42ca-8143-546a111d6458"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Pranav
2025-01-27 19:49:18 -08:00
committed by GitHub
parent 9cee8a1713
commit cb42be8e65
29 changed files with 1026 additions and 107 deletions

View File

@@ -28,7 +28,7 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range)
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
end
def reporting_events

View File

@@ -1,58 +1,70 @@
module Api::V2::Accounts::ReportsHelper
def generate_agents_report
reports = V2::Reports::AgentSummaryBuilder.new(
account: Current.account,
params: build_params(type: :agent)
).build
Current.account.users.map do |agent|
agent_report = report_builder({ type: :agent, id: agent.id }).summary
[agent.name] + generate_readable_report_metrics(agent_report)
report = reports.find { |r| r[:id] == agent.id }
[agent.name] + generate_readable_report_metrics(report)
end
end
def generate_inboxes_report
reports = V2::Reports::InboxSummaryBuilder.new(
account: Current.account,
params: build_params(type: :inbox)
).build
Current.account.inboxes.map do |inbox|
inbox_report = generate_report({ type: :inbox, id: inbox.id })
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(inbox_report)
report = reports.find { |r| r[:id] == inbox.id }
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(report)
end
end
def generate_teams_report
reports = V2::Reports::TeamSummaryBuilder.new(
account: Current.account,
params: build_params(type: :team)
).build
Current.account.teams.map do |team|
team_report = report_builder({ type: :team, id: team.id }).summary
[team.name] + generate_readable_report_metrics(team_report)
report = reports.find { |r| r[:id] == team.id }
[team.name] + generate_readable_report_metrics(report)
end
end
def generate_labels_report
Current.account.labels.map do |label|
label_report = generate_report({ type: :label, id: label.id })
label_report = report_builder({ type: :label, id: label.id }).short_summary
[label.title] + generate_readable_report_metrics(label_report)
end
end
def report_builder(report_params)
V2::ReportBuilder.new(
Current.account,
report_params.merge(
{
since: params[:since],
until: params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
}
)
private
def build_params(base_params)
base_params.merge(
{
since: params[:since],
until: params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
}
)
end
def generate_report(report_params)
report_builder(report_params).short_summary
def report_builder(report_params)
V2::ReportBuilder.new(Current.account, build_params(report_params))
end
private
def generate_readable_report_metrics(report_metric)
def generate_readable_report_metrics(report)
[
report_metric[:conversations_count],
Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:reply_time]).format,
report_metric[:resolutions_count]
report[:conversations_count],
Reports::TimeFormatPresenter.new(report[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(report[:avg_resolution_time]).format,
Reports::TimeFormatPresenter.new(report[:avg_reply_time]).format,
report[:resolved_conversations_count]
]
end
end

View File

@@ -0,0 +1,40 @@
/* global axios */
import ApiClient from './ApiClient';
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
}
getTeamReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/team`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getAgentReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/agent`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getInboxReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/inbox`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
}
export default new SummaryReportsAPI();

View File

@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -36,6 +37,18 @@ const toggleShortcutModalFn = show => {
}
};
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showV4Routes = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.REPORT_V4
);
});
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
// We're using localStorage to store the expanded item in the sidebar
@@ -77,6 +90,59 @@ const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const newReportRoutes = [
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports_index'),
activeOn: ['agent_reports_show'],
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports_index'),
activeOn: ['inbox_reports_show'],
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports_index'),
activeOn: ['team_reports_show'],
},
];
const oldReportRoutes = [
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
];
const reportRoutes = computed(() =>
showV4Routes.value ? newReportRoutes : oldReportRoutes
);
const menuItems = computed(() => {
return [
{
@@ -265,31 +331,12 @@ const menuItems = computed(() => {
label: t('SIDEBAR.REPORTS_CONVERSATION'),
to: accountScopedRoute('conversation_reports'),
},
...reportRoutes.value,
{
name: 'Reports CSAT',
label: t('SIDEBAR.CSAT'),
to: accountScopedRoute('csat_reports'),
},
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
{
name: 'Reports SLA',
label: t('SIDEBAR.REPORTS_SLA'),

View File

@@ -11,6 +11,7 @@ const reports = accountId => ({
'agent_reports',
'label_reports',
'inbox_reports',
'inbox_reports_show',
'team_reports',
'sla_reports',
],

View File

@@ -40,7 +40,7 @@ const headerClass = computed(() =>
:style="{
width: `${header.getSize()}px`,
}"
class="text-left py-3 px-5 font-normal text-sm"
class="text-left py-3 px-5 font-medium text-sm text-n-slate-12"
:class="headerClass"
@click="header.column.getCanSort() && header.column.toggleSorting()"
>

View File

@@ -37,8 +37,10 @@ const buttonStyleClass = props.compact
>
<Icon
icon="i-lucide-chevron-left"
class="size-5 ltr:-ml-1 rtl:-mr-1"
:class="props.compact ? 'text-n-slate-11' : 'text-n-blue-text'"
class="ltr:-ml-1 rtl:-mr-1"
:class="
props.compact ? 'text-n-slate-11 size-4' : 'text-n-blue-text size-5'
"
/>
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</button>

View File

@@ -33,4 +33,5 @@ export const FEATURE_FLAGS = {
CAPTAIN: 'captain_integration',
CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4',
};

View File

@@ -124,6 +124,7 @@
},
"AGENT_REPORTS": {
"HEADER": "Agents Overview",
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agents name to learn more.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
@@ -258,6 +259,7 @@
},
"INBOX_REPORTS": {
"HEADER": "Inbox Overview",
"DESCRIPTION": "Quickly view your inbox performance with key metrics like conversations, response times, resolution times, and resolved cases—all in one place. Click an inbox name for more details.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
@@ -325,6 +327,7 @@
},
"TEAM_REPORTS": {
"HEADER": "Team Overview",
"DESCRIPTION": "Get a snapshot of your teams performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
@@ -538,5 +541,15 @@
},
"VIEW_DETAILS": "View Details"
}
},
"SUMMARY_REPORTS": {
"INBOX": "Inbox",
"AGENT": "Agent",
"TEAM": "Team",
"AVG_RESOLUTION_TIME": "Avg. Resolution Time",
"AVG_FIRST_RESPONSE_TIME": "Avg. First Response Time",
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
"RESOLUTION_COUNT": "Resolution Count",
"CONVERSATIONS": "No. of conversations"
}
}

View File

@@ -0,0 +1,35 @@
<script setup>
import { ref } from 'vue';
import ReportHeader from './components/ReportHeader.vue';
import SummaryReports from './components/SummaryReports.vue';
import V4Button from 'dashboard/components-next/button/Button.vue';
const summarReportsRef = ref(null);
const onDownloadClick = () => {
summarReportsRef.value.downloadReports();
};
</script>
<template>
<ReportHeader
:header-title="$t('AGENT_REPORTS.HEADER')"
:header-description="$t('AGENT_REPORTS.DESCRIPTION')"
>
<V4Button
:label="$t('AGENT_REPORTS.DOWNLOAD_AGENT_REPORTS')"
icon="i-ph-download-simple"
size="sm"
@click="onDownloadClick"
/>
</ReportHeader>
<SummaryReports
ref="summarReportsRef"
action-key="summaryReports/fetchAgentSummaryReports"
getter-key="agents/getAgents"
fetch-items-key="agents/get"
summary-key="summaryReports/getAgentSummaryReports"
type="agent"
/>
</template>

View File

@@ -0,0 +1,31 @@
<script setup>
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
import WootReports from './components/WootReports.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const route = useRoute();
const store = useStore();
const agent = useFunctionGetter('agents/getAgentById', route.params.id);
onMounted(() => store.dispatch('agents/get'));
</script>
<template>
<WootReports
v-if="agent.id"
:key="agent.id"
type="agent"
getter-key="agents/getAgents"
action-key="agents/get"
:selected-item="agent"
:download-button-label="$t('AGENT_REPORTS.DOWNLOAD_AGENT_REPORTS')"
:report-title="$t('AGENT_REPORTS.HEADER')"
has-back-button
/>
<div v-else class="w-full py-20">
<Spinner class="mx-auto" />
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup>
import { ref } from 'vue';
import ReportHeader from './components/ReportHeader.vue';
import SummaryReports from './components/SummaryReports.vue';
import V4Button from 'dashboard/components-next/button/Button.vue';
const summarReportsRef = ref(null);
const onDownloadClick = () => {
summarReportsRef.value.downloadReports();
};
</script>
<template>
<ReportHeader
:header-title="$t('INBOX_REPORTS.HEADER')"
:header-description="$t('INBOX_REPORTS.DESCRIPTION')"
>
<V4Button
:label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
icon="i-ph-download-simple"
size="sm"
@click="onDownloadClick"
/>
</ReportHeader>
<SummaryReports
ref="summarReportsRef"
action-key="summaryReports/fetchInboxSummaryReports"
getter-key="inboxes/getInboxes"
fetch-items-key="inboxes/get"
summary-key="summaryReports/getInboxSummaryReports"
type="inbox"
/>
</template>

View File

@@ -0,0 +1,27 @@
<script setup>
import { useRoute } from 'vue-router';
import { useFunctionGetter } from 'dashboard/composables/store';
import WootReports from './components/WootReports.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const route = useRoute();
const inbox = useFunctionGetter('inboxes/getInboxById', route.params.id);
</script>
<template>
<WootReports
v-if="inbox.id"
:key="inbox.id"
type="inbox"
getter-key="inboxes/getInboxes"
action-key="inboxes/get"
:selected-item="inbox"
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
:report-title="$t('INBOX_REPORTS.HEADER')"
has-back-button
/>
<div v-else class="w-full py-20">
<Spinner class="mx-auto" />
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup>
import { ref } from 'vue';
import ReportHeader from './components/ReportHeader.vue';
import SummaryReports from './components/SummaryReports.vue';
import V4Button from 'dashboard/components-next/button/Button.vue';
const summarReportsRef = ref(null);
const onDownloadClick = () => {
summarReportsRef.value.downloadReports();
};
</script>
<template>
<ReportHeader
:header-title="$t('TEAM_REPORTS.HEADER')"
:header-description="$t('TEAM_REPORTS.DESCRIPTION')"
>
<V4Button
:label="$t('TEAM_REPORTS.DOWNLOAD_TEAM_REPORTS')"
icon="i-ph-download-simple"
size="sm"
@click="onDownloadClick"
/>
</ReportHeader>
<SummaryReports
ref="summarReportsRef"
action-key="summaryReports/fetchTeamSummaryReports"
getter-key="teams/getTeams"
fetch-items-key="teams/get"
summary-key="summaryReports/getTeamSummaryReports"
type="team"
/>
</template>

View File

@@ -0,0 +1,27 @@
<script setup>
import { useRoute } from 'vue-router';
import { useFunctionGetter } from 'dashboard/composables/store';
import WootReports from './components/WootReports.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const route = useRoute();
const team = useFunctionGetter('teams/getTeamById', route.params.id);
</script>
<template>
<WootReports
v-if="team.id"
:key="team.id"
type="team"
getter-key="teams/getTeams"
action-key="teams/get"
:selected-item="team"
:download-button-label="$t('TEAM_REPORTS.DOWNLOAD_TEAM_REPORTS')"
:report-title="$t('TEAM_REPORTS.HEADER')"
has-back-button
/>
<div v-else class="w-full py-20">
<Spinner class="mx-auto" />
</div>
</template>

View File

@@ -15,6 +15,10 @@ export default {
Thumbnail,
},
props: {
currentFilter: {
type: Object,
default: () => null,
},
filterItemsList: {
type: Array,
default: () => [],
@@ -40,7 +44,7 @@ export default {
],
data() {
return {
currentSelectedFilter: null,
currentSelectedFilter: this.currentFilter || null,
currentDateRangeSelection: {
id: 0,
name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
@@ -113,7 +117,9 @@ export default {
},
watch: {
filterItemsList(val) {
this.currentSelectedFilter = val[0];
this.currentSelectedFilter = !this.currentFilter
? val[0]
: this.currentFilter;
this.changeFilterSelection();
},
groupByFilterItemsList() {

View File

@@ -1,17 +1,41 @@
<script setup>
import BackButton from 'dashboard/components/widgets/BackButton.vue';
defineProps({
headerTitle: {
required: true,
type: String,
},
headerDescription: {
type: String,
default: '',
},
hasBackButton: {
type: Boolean,
default: false,
},
});
</script>
<template>
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
<slot />
</div>
<section class="flex flex-col gap-1 pt-10 pb-5">
<div v-if="hasBackButton">
<BackButton compact />
</div>
<div class="flex justify-between w-full gap-5">
<div class="flex flex-col gap-2">
<div>
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
<p v-if="headerDescription" class="text-n-slate-12 mt-2">
{{ headerDescription }}
</p>
</div>
</div>
<div class="flex-shrink-0">
<slot />
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,20 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
row: {
type: Object,
required: true,
},
});
const routeName = computed(() => `${props.row.original.type}_reports_show`);
</script>
<template>
<router-link
:to="{ name: routeName, params: { id: row.original.id } }"
class="text-n-slate-12 hover:underline"
>
{{ row.original.name }}
</router-link>
</template>

View File

@@ -0,0 +1,184 @@
<script setup>
import ReportFilterSelector from './FilterSelector.vue';
import { formatTime } from '@chatwoot/utils';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import Table from 'dashboard/components/table/Table.vue';
import { generateFileName } from 'dashboard/helper/downloadHelper';
import {
useVueTable,
createColumnHelper,
getCoreRowModel,
} from '@tanstack/vue-table';
import { computed, onMounted, ref, h } from 'vue';
const props = defineProps({
type: {
type: String,
default: 'account',
},
getterKey: {
type: String,
default: '',
},
actionKey: {
type: String,
default: '',
},
summaryKey: {
type: String,
default: '',
},
fetchItemsKey: {
type: String,
required: true,
},
});
const store = useStore();
const from = ref(0);
const to = ref(0);
const businessHours = ref(false);
import { useI18n } from 'vue-i18n';
import SummaryReportLink from './SummaryReportLink.vue';
const rowItems = useMapGetter([props.getterKey]) || [];
const reportMetrics = useMapGetter([props.summaryKey]) || [];
const getMetrics = id =>
reportMetrics.value.find(metrics => metrics.id === Number(id)) || {};
const columnHelper = createColumnHelper();
const { t } = useI18n();
const defaulSpanRender = cellProps =>
h(
'span',
{
class: cellProps.getValue() ? '' : 'text-n-slate-12',
},
cellProps.getValue()
);
const columns = [
columnHelper.accessor('name', {
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
width: 300,
cell: cellProps => h(SummaryReportLink, cellProps),
}),
columnHelper.accessor('conversationsCount', {
header: t('SUMMARY_REPORTS.CONVERSATIONS'),
width: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('avgFirstResponseTime', {
header: t('SUMMARY_REPORTS.AVG_FIRST_RESPONSE_TIME'),
width: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('avgResolutionTime', {
header: t('SUMMARY_REPORTS.AVG_RESOLUTION_TIME'),
width: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('avgReplyTime', {
header: t('SUMMARY_REPORTS.AVG_REPLY_TIME'),
width: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('resolutionsCount', {
header: t('SUMMARY_REPORTS.RESOLUTION_COUNT'),
width: 200,
cell: defaulSpanRender,
}),
];
const renderAvgTime = value => (value ? formatTime(value) : '--');
const renderCount = value => (value ? value.toLocaleString() : '--');
const tableData = computed(() =>
rowItems.value.map(row => {
const rowMetrics = getMetrics(row.id);
const {
conversationsCount,
avgFirstResponseTime,
avgResolutionTime,
avgReplyTime,
resolvedConversationsCount,
} = rowMetrics;
return {
id: row.id,
name: row.name,
type: props.type,
conversationsCount: renderCount(conversationsCount),
avgFirstResponseTime: renderAvgTime(avgFirstResponseTime),
avgReplyTime: renderAvgTime(avgReplyTime),
avgResolutionTime: renderAvgTime(avgResolutionTime),
resolutionsCount: renderCount(resolvedConversationsCount),
};
})
);
const fetchAllData = () => {
store.dispatch(props.fetchItemsKey);
store.dispatch(props.actionKey, {
since: from.value,
until: to.value,
businessHours: businessHours.value,
});
};
onMounted(() => fetchAllData());
const onFilterChange = updatedFilter => {
from.value = updatedFilter.from;
to.value = updatedFilter.to;
businessHours.value = updatedFilter.businessHours;
fetchAllData();
};
const table = useVueTable({
get data() {
return tableData.value;
},
columns,
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
});
// downloadReports method is not used in this component
// but it is exposed to be used in the parent component
const downloadReports = () => {
const dispatchMethods = {
agent: 'downloadAgentReports',
label: 'downloadLabelReports',
inbox: 'downloadInboxReports',
team: 'downloadTeamReports',
};
if (dispatchMethods[props.type]) {
const fileName = generateFileName({
type: props.type,
to: to.value,
businessHours: businessHours.value,
});
const params = {
from: from.value,
to: to.value,
fileName,
businessHours: businessHours.value,
};
store.dispatch(dispatchMethods[props.type], params);
}
};
defineExpose({ downloadReports });
</script>
<template>
<ReportFilterSelector @filter-change="onFilterChange" />
<div
class="flex-1 overflow-auto px-5 py-6 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
>
<Table :table="table" />
</div>
</template>

View File

@@ -54,12 +54,20 @@ export default {
type: String,
default: 'Download Reports',
},
hasBackButton: {
type: Boolean,
default: false,
},
selectedItem: {
type: Object,
default: null,
},
},
data() {
return {
from: 0,
to: 0,
selectedFilter: null,
selectedFilter: this.selectedItem,
groupBy: GROUP_BY_FILTER[1],
groupByfilterItemsList: GROUP_BY_OPTIONS.DAY.map(this.translateOptions),
selectedGroupByFilter: null,
@@ -206,7 +214,7 @@ export default {
</script>
<template>
<ReportHeader :header-title="reportTitle">
<ReportHeader :header-title="reportTitle" :has-back-button="hasBackButton">
<V4Button
:label="downloadButtonLabel"
icon="i-ph-download-simple"
@@ -214,13 +222,13 @@ export default {
@click="downloadReports"
/>
</ReportHeader>
<ReportFilters
v-if="filterItemsList"
:type="type"
:filter-items-list="filterItemsList"
:group-by-filter-items-list="groupByfilterItemsList"
:selected-group-by-filter="selectedGroupByFilter"
:current-filter="selectedFilter"
@date-range-change="onDateRangeChange"
@filter-change="onFilterChange"
@group-by-filter-change="onGroupByFilterChange"

View File

@@ -3,15 +3,112 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ReportsWrapper from './components/ReportsWrapper.vue';
import Index from './Index.vue';
import AgentReportsIndex from './AgentReportsIndex.vue';
import InboxReportsIndex from './InboxReportsIndex.vue';
import TeamReportsIndex from './TeamReportsIndex.vue';
import AgentReportsShow from './AgentReportsShow.vue';
import InboxReportsShow from './InboxReportsShow.vue';
import TeamReportsShow from './TeamReportsShow.vue';
import AgentReports from './AgentReports.vue';
import LabelReports from './LabelReports.vue';
import InboxReports from './InboxReports.vue';
import LabelReports from './LabelReports.vue';
import TeamReports from './TeamReports.vue';
import CsatResponses from './CsatResponses.vue';
import BotReports from './BotReports.vue';
import LiveReports from './LiveReports.vue';
import SLAReports from './SLAReports.vue';
const oldReportRoutes = [
{
path: 'agent',
name: 'agent_reports',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: AgentReports,
},
{
path: 'inboxes',
name: 'inbox_reports',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: InboxReports,
},
{
path: 'label',
name: 'label_reports',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: LabelReports,
},
{
path: 'teams',
name: 'team_reports',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: TeamReports,
},
];
const revisedReportRoutes = [
{
path: 'agents_overview',
name: 'agent_reports_index',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: AgentReportsIndex,
},
{
path: 'agents/:id',
name: 'agent_reports_show',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: AgentReportsShow,
},
{
path: 'inboxes_overview',
name: 'inbox_reports_index',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: InboxReportsIndex,
},
{
path: 'inboxes/:id',
name: 'inbox_reports_show',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: InboxReportsShow,
},
{
path: 'teams_overview',
name: 'team_reports_index',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: TeamReportsIndex,
},
{
path: 'teams/:id',
name: 'team_reports_show',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: TeamReportsShow,
},
];
export default {
routes: [
{
@@ -40,38 +137,8 @@ export default {
},
component: Index,
},
{
path: 'agent',
name: 'agent_reports',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: AgentReports,
},
{
path: 'label',
name: 'label_reports',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: LabelReports,
},
{
path: 'inboxes',
name: 'inbox_reports',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: InboxReports,
},
{
path: 'teams',
name: 'team_reports',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: TeamReports,
},
...oldReportRoutes,
...revisedReportRoutes,
{
path: 'sla',
name: 'sla_reports',

View File

@@ -5,8 +5,8 @@ import agentBots from './modules/agentBots';
import agents from './modules/agents';
import articles from './modules/helpCenterArticles';
import attributes from './modules/attributes';
import auth from './modules/auth';
import auditlogs from './modules/auditlogs';
import auth from './modules/auth';
import automations from './modules/automations';
import bulkActions from './modules/bulkActions';
import campaigns from './modules/campaigns';
@@ -25,9 +25,10 @@ import conversationStats from './modules/conversationStats';
import conversationTypingStatus from './modules/conversationTypingStatus';
import conversationWatchers from './modules/conversationWatchers';
import csat from './modules/csat';
import customViews from './modules/customViews';
import customRole from './modules/customRole';
import customViews from './modules/customViews';
import dashboardApps from './modules/dashboardApps';
import draftMessages from './modules/draftMessages';
import globalConfig from 'shared/store/globalConfig';
import inboxAssignableAgents from './modules/inboxAssignableAgents';
import inboxes from './modules/inboxes';
@@ -39,12 +40,12 @@ import notifications from './modules/notifications';
import portals from './modules/helpCenterPortals';
import reports from './modules/reports';
import sla from './modules/sla';
import slaReports from './modules/SLAReports';
import summaryReports from './modules/summaryReports';
import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
import draftMessages from './modules/draftMessages';
import SLAReports from './modules/SLAReports';
import captainAssistants from './captain/assistant';
import captainDocuments from './captain/document';
import captainResponses from './captain/response';
@@ -58,9 +59,9 @@ export default createStore({
agents,
articles,
attributes,
auditlogs,
auth,
automations,
auditlogs,
bulkActions,
campaigns,
cannedResponse,
@@ -78,9 +79,10 @@ export default createStore({
conversationTypingStatus,
conversationWatchers,
csat,
customViews,
customRole,
customViews,
dashboardApps,
draftMessages,
globalConfig,
inboxAssignableAgents,
inboxes,
@@ -91,13 +93,13 @@ export default createStore({
notifications,
portals,
reports,
sla,
slaReports,
summaryReports,
teamMembers,
teams,
userNotificationSettings,
webhooks,
draftMessages,
sla,
slaReports: SLAReports,
captainAssistants,
captainDocuments,
captainResponses,

View File

@@ -22,6 +22,9 @@ export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
getAgentById: $state => id => {
return $state.records.find(record => record.id === Number(id)) || {};
},
getAgentStatus($state) {
let status = {
online: $state.records.filter(

View File

@@ -0,0 +1,189 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import SummaryReportsAPI from 'dashboard/api/summaryReports';
import store, { initialState } from '../summaryReports';
vi.mock('dashboard/api/summaryReports', () => ({
default: {
getInboxReports: vi.fn(),
getAgentReports: vi.fn(),
getTeamReports: vi.fn(),
},
}));
describe('Summary Reports Store', () => {
let commit;
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
commit = vi.fn();
});
describe('Initial State', () => {
it('should have the correct initial state structure', () => {
expect(initialState).toEqual({
inboxSummaryReports: [],
agentSummaryReports: [],
teamSummaryReports: [],
uiFlags: {
isFetchingInboxSummaryReports: false,
isFetchingAgentSummaryReports: false,
isFetchingTeamSummaryReports: false,
},
});
});
});
describe('Getters', () => {
const state = {
inboxSummaryReports: [{ id: 1 }],
agentSummaryReports: [{ id: 2 }],
teamSummaryReports: [{ id: 3 }],
uiFlags: { isFetchingInboxSummaryReports: true },
};
it('should return inbox summary reports', () => {
expect(store.getters.getInboxSummaryReports(state)).toEqual([{ id: 1 }]);
});
it('should return agent summary reports', () => {
expect(store.getters.getAgentSummaryReports(state)).toEqual([{ id: 2 }]);
});
it('should return team summary reports', () => {
expect(store.getters.getTeamSummaryReports(state)).toEqual([{ id: 3 }]);
});
it('should return UI flags', () => {
expect(store.getters.getUIFlags(state)).toEqual({
isFetchingInboxSummaryReports: true,
});
});
});
describe('Mutations', () => {
it('should set inbox summary report', () => {
const state = { ...initialState };
const data = [{ id: 1 }];
store.mutations.setInboxSummaryReport(state, data);
expect(state.inboxSummaryReports).toEqual(data);
});
it('should set agent summary report', () => {
const state = { ...initialState };
const data = [{ id: 2 }];
store.mutations.setAgentSummaryReport(state, data);
expect(state.agentSummaryReports).toEqual(data);
});
it('should set team summary report', () => {
const state = { ...initialState };
const data = [{ id: 3 }];
store.mutations.setTeamSummaryReport(state, data);
expect(state.teamSummaryReports).toEqual(data);
});
it('should merge UI flags with existing flags', () => {
const state = {
uiFlags: { flag1: true, flag2: false },
};
const newFlags = { flag2: true, flag3: true };
store.mutations.setUIFlags(state, newFlags);
expect(state.uiFlags).toEqual({
flag1: true,
flag2: true,
flag3: true,
});
});
});
describe('Actions', () => {
describe('fetchInboxSummaryReports', () => {
it('should fetch inbox reports successfully', async () => {
const params = { date: '2025-01-01' };
const mockResponse = {
data: [{ report_id: 1, report_name: 'Test' }],
};
SummaryReportsAPI.getInboxReports.mockResolvedValue(mockResponse);
await store.actions.fetchInboxSummaryReports({ commit }, params);
expect(commit).toHaveBeenCalledWith('setUIFlags', {
isFetchingInboxSummaryReports: true,
});
expect(SummaryReportsAPI.getInboxReports).toHaveBeenCalledWith(params);
expect(commit).toHaveBeenCalledWith('setInboxSummaryReport', [
{ reportId: 1, reportName: 'Test' },
]);
expect(commit).toHaveBeenCalledWith('setUIFlags', {
isFetchingInboxSummaryReports: false,
});
});
it('should handle errors gracefully', async () => {
SummaryReportsAPI.getInboxReports.mockRejectedValue(
new Error('API Error')
);
await store.actions.fetchInboxSummaryReports({ commit }, {});
expect(commit).toHaveBeenCalledWith('setUIFlags', {
isFetchingInboxSummaryReports: false,
});
});
});
describe('fetchAgentSummaryReports', () => {
it('should fetch agent reports successfully', async () => {
const params = { agentId: 123 };
const mockResponse = {
data: [{ agent_id: 123, agent_name: 'Test Agent' }],
};
SummaryReportsAPI.getAgentReports.mockResolvedValue(mockResponse);
await store.actions.fetchAgentSummaryReports({ commit }, params);
expect(commit).toHaveBeenCalledWith('setUIFlags', {
isFetchingAgentSummaryReports: true,
});
expect(SummaryReportsAPI.getAgentReports).toHaveBeenCalledWith(params);
expect(commit).toHaveBeenCalledWith('setAgentSummaryReport', [
{ agentId: 123, agentName: 'Test Agent' },
]);
expect(commit).toHaveBeenCalledWith('setUIFlags', {
isFetchingAgentSummaryReports: false,
});
});
});
describe('fetchTeamSummaryReports', () => {
it('should fetch team reports successfully', async () => {
const params = { teamId: 456 };
const mockResponse = {
data: [{ team_id: 456, team_name: 'Test Team' }],
};
SummaryReportsAPI.getTeamReports.mockResolvedValue(mockResponse);
await store.actions.fetchTeamSummaryReports({ commit }, params);
expect(commit).toHaveBeenCalledWith('setUIFlags', {
isFetchingTeamSummaryReports: true,
});
expect(SummaryReportsAPI.getTeamReports).toHaveBeenCalledWith(params);
expect(commit).toHaveBeenCalledWith('setTeamSummaryReport', [
{ teamId: 456, teamName: 'Test Team' },
]);
expect(commit).toHaveBeenCalledWith('setUIFlags', {
isFetchingTeamSummaryReports: false,
});
});
});
});
});

View File

@@ -0,0 +1,98 @@
import SummaryReportsAPI from 'dashboard/api/summaryReports';
import camelcaseKeys from 'camelcase-keys';
const typeMap = {
inbox: {
flagKey: 'isFetchingInboxSummaryReports',
apiMethod: 'getInboxReports',
mutationKey: 'setInboxSummaryReport',
},
agent: {
flagKey: 'isFetchingAgentSummaryReports',
apiMethod: 'getAgentReports',
mutationKey: 'setAgentSummaryReport',
},
team: {
flagKey: 'isFetchingTeamSummaryReports',
apiMethod: 'getTeamReports',
mutationKey: 'setTeamSummaryReport',
},
};
async function fetchSummaryReports(type, params, { commit }) {
const config = typeMap[type];
if (!config) return;
try {
commit('setUIFlags', { [config.flagKey]: true });
const response = await SummaryReportsAPI[config.apiMethod](params);
commit(config.mutationKey, camelcaseKeys(response.data, { deep: true }));
} catch (error) {
// Ignore error
} finally {
commit('setUIFlags', { [config.flagKey]: false });
}
}
export const initialState = {
inboxSummaryReports: [],
agentSummaryReports: [],
teamSummaryReports: [],
uiFlags: {
isFetchingInboxSummaryReports: false,
isFetchingAgentSummaryReports: false,
isFetchingTeamSummaryReports: false,
},
};
export const getters = {
getInboxSummaryReports(state) {
return state.inboxSummaryReports;
},
getAgentSummaryReports(state) {
return state.agentSummaryReports;
},
getTeamSummaryReports(state) {
return state.teamSummaryReports;
},
getUIFlags(state) {
return state.uiFlags;
},
};
export const actions = {
fetchInboxSummaryReports({ commit }, params) {
return fetchSummaryReports('inbox', params, { commit });
},
fetchAgentSummaryReports({ commit }, params) {
return fetchSummaryReports('agent', params, { commit });
},
fetchTeamSummaryReports({ commit }, params) {
return fetchSummaryReports('team', params, { commit });
},
};
export const mutations = {
setInboxSummaryReport(state, data) {
state.inboxSummaryReports = data;
},
setAgentSummaryReport(state, data) {
state.agentSummaryReports = data;
},
setTeamSummaryReport(state, data) {
state.teamSummaryReports = data;
},
setUIFlags(state, uiFlag) {
state.uiFlags = { ...state.uiFlags, ...uiFlag };
},
};
export default {
namespaced: true,
state: initialState,
getters,
actions,
mutations,
};

View File

@@ -2,6 +2,12 @@ export const getters = {
getTeams($state) {
return Object.values($state.records).sort((a, b) => a.id - b.id);
},
getTeamById: $state => id => {
return (
Object.values($state.records).find(record => record.id === Number(id)) ||
{}
);
},
getMyTeams($state, $getters) {
return $getters.getTeams.filter(team => {
const { is_member: isMember } = team;

View File

@@ -3,12 +3,12 @@ class Reports::TimeFormatPresenter
attr_reader :seconds
def initialize(seconds)
@seconds = seconds.to_i
def initialize(seconds = nil)
@seconds = seconds.to_i if seconds.present?
end
def format
return '--' if seconds.nil? || seconds.zero?
return 'N/A' if seconds.nil? || seconds.zero?
days, remainder = seconds.divmod(86_400)
hours, remainder = remainder.divmod(3600)

View File

@@ -94,3 +94,5 @@
premium: true
- name: chatwoot_v4
enabled: false
- name: report_v4
enabled: false

View File

@@ -68,6 +68,10 @@ RSpec.describe Reports::TimeFormatPresenter do
it 'formats single second correctly' do
expect(described_class.new(1).format).to eq '1 second'
end
it 'formats nil second correctly' do
expect(described_class.new.format).to eq 'N/A'
end
end
end
end