Merge branch 'develop' into fix/next-message-bubbles

This commit is contained in:
Sivin Varghese
2025-01-28 19:12:05 +05:30
committed by GitHub
34 changed files with 1105 additions and 110 deletions
@@ -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();
@@ -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'),
@@ -11,6 +11,7 @@ const reports = accountId => ({
'agent_reports',
'label_reports',
'inbox_reports',
'inbox_reports_show',
'team_reports',
'sla_reports',
],
@@ -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()"
>
@@ -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>
+1
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',
};
@@ -3,6 +3,7 @@ import {
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
import wootConstants from 'dashboard/constants/globals';
class AudioNotificationStore {
constructor(store) {
@@ -18,6 +19,15 @@ class AudioNotificationStore {
return mineConversation.some(conv => conv.unread_count > 0);
};
isMessageFromPendingConversation = (message = {}) => {
const { conversation_id: conversationId } = message || {};
if (!conversationId) return false;
const activeConversation =
this.store.getters.getConversationById(conversationId);
return activeConversation?.status === wootConstants.STATUS_TYPE.PENDING;
};
isMessageFromCurrentConversation = message => {
return this.store.getters.getSelectedChat?.id === message.conversation_id;
};
@@ -172,6 +172,12 @@ export class DashboardAudioNotificationHelper {
return;
}
// If the conversation status is pending, then dismiss the alert
// This case is common for all audio event types
if (this.store.isMessageFromPendingConversation(message)) {
return;
}
// If the message is sent by the current user then dismiss the alert
if (isMessageFromCurrentUser(message, this.currentUser.id)) {
return;
@@ -4,6 +4,8 @@ import {
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
import wootConstants from 'dashboard/constants/globals';
vi.mock('dashboard/helper/permissionsHelper', () => ({
getUserPermissions: vi.fn(),
}));
@@ -18,6 +20,7 @@ describe('AudioNotificationStore', () => {
getMineChats: vi.fn(),
getSelectedChat: null,
getCurrentAccountId: 1,
getConversationById: vi.fn(),
},
};
audioNotificationStore = new AudioNotificationStore(store);
@@ -59,6 +62,63 @@ describe('AudioNotificationStore', () => {
});
});
describe('isMessageFromPendingConversation', () => {
it('should return true when conversation status is pending', () => {
store.getters.getConversationById.mockReturnValue({
id: 123,
status: wootConstants.STATUS_TYPE.PENDING,
});
const message = { conversation_id: 123 };
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(true);
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
});
it('should return false when conversation status is not pending', () => {
store.getters.getConversationById.mockReturnValue({
id: 123,
status: wootConstants.STATUS_TYPE.OPEN,
});
const message = { conversation_id: 123 };
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(false);
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
});
it('should return false when conversation is not found', () => {
store.getters.getConversationById.mockReturnValue(null);
const message = { conversation_id: 123 };
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(false);
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
});
it('should return false when message has no conversation_id', () => {
const message = {};
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(false);
expect(store.getters.getConversationById).not.toHaveBeenCalled();
});
it('should return false when message is null or undefined', () => {
expect(
audioNotificationStore.isMessageFromPendingConversation(null)
).toBe(false);
expect(
audioNotificationStore.isMessageFromPendingConversation(undefined)
).toBe(false);
expect(store.getters.getConversationById).not.toHaveBeenCalled();
});
});
describe('isMessageFromCurrentConversation', () => {
it('should return true when message is from selected chat', () => {
store.getters.getSelectedChat = { id: 6179 };
@@ -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"
}
}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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() {
@@ -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>
@@ -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>
@@ -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>
@@ -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"
@@ -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',
+11 -9
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,
@@ -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(
@@ -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,
});
});
});
});
});
@@ -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,
};
@@ -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;