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

@@ -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',