mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 11:37:58 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
40
app/javascript/dashboard/api/summaryReports.js
Normal file
40
app/javascript/dashboard/api/summaryReports.js
Normal 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();
|
||||
@@ -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>
|
||||
|
||||
@@ -33,4 +33,5 @@ export const FEATURE_FLAGS = {
|
||||
CAPTAIN: 'captain_integration',
|
||||
CUSTOM_ROLES: 'custom_roles',
|
||||
CHATWOOT_V4: 'chatwoot_v4',
|
||||
REPORT_V4: 'report_v4',
|
||||
};
|
||||
|
||||
@@ -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 agent’s 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 team’s 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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
98
app/javascript/dashboard/store/modules/summaryReports.js
Normal file
98
app/javascript/dashboard/store/modules/summaryReports.js
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -94,3 +94,5 @@
|
||||
premium: true
|
||||
- name: chatwoot_v4
|
||||
enabled: false
|
||||
- name: report_v4
|
||||
enabled: false
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user