Compare commits

...
Author SHA1 Message Date
Pranav e7847e3f69 Fix branding 2025-02-05 21:36:07 -08:00
Pranav 4a63b54630 CSVSafe -> CSV 2025-02-05 20:59:47 -08:00
Pranav 401babb844 Update Open Conversations 2025-02-05 20:59:08 -08:00
PranavandGitHub 3500b1a487 Merge branch 'heatmap-last-30-days' into live-agent-reports-controller 2025-02-05 20:08:13 -08:00
PranavandGitHub 2cf8856b62 Merge branch 'develop' into heatmap-last-30-days 2025-02-05 20:08:02 -08:00
Pranav 698642fecb Add live team reports 2025-02-05 19:26:46 -08:00
Pranav b235d66f81 Add liveagentreports 2025-02-05 19:13:24 -08:00
Pranav 2545e26e8f Fix eslint 2025-02-05 19:06:20 -08:00
Pranav 6386142348 feat: Allow users to see heatmap for last 30 days 2025-02-05 18:33:23 -08:00
25 changed files with 716 additions and 369 deletions
@@ -0,0 +1,58 @@
class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController
before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics]
before_action :set_group_scope, only: [:grouped_conversation_metrics]
def conversation_metrics
render json: {
open: @conversations.open.count,
unattended: @conversations.open.unattended.count,
unassigned: @conversations.open.unassigned.count,
pending: @conversations.pending.count
}
end
def grouped_conversation_metrics
count_by_group = @conversations.open.group(@group_scope).count
unattended_by_group = @conversations.open.unattended.group(@group_scope).count
unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count
group_metrics = count_by_group.map do |group_id, count|
metric = {
open: count,
unattended: unattended_by_group[group_id] || 0,
unassigned: unassigned_by_group[group_id] || 0
}
metric[@group_scope] = group_id
metric
end
render json: group_metrics
end
private
def set_group_scope
render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[
team_id
assignee_id
].include?(permitted_params[:group_by])
@group_scope = permitted_params[:group_by]
end
def team
return unless permitted_params[:team_id]
@team ||= Current.account.teams.find(permitted_params[:team_id])
end
def load_conversations
scope = Current.account.conversations
scope = scope.where(team_id: team.id) if team.present?
@conversations = scope
end
def permitted_params
params.permit(:team_id, :group_by)
end
end
@@ -94,7 +94,8 @@ module Api::V2::Accounts::HeatmapHelper
end
def since_timestamp(date)
(date - 6.days).to_i.to_s
number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days
(date - number_of_days).to_i.to_s
end
def until_timestamp(date)
@@ -0,0 +1,20 @@
/* global axios */
import ApiClient from './ApiClient';
class LiveReportsAPI extends ApiClient {
constructor() {
super('live_reports', { accountScoped: true, apiVersion: 'v2' });
}
getConversationMetric(params = {}) {
return axios.get(`${this.url}/conversation_metrics`, { params });
}
getGroupedConversations({ groupBy } = { groupBy: 'assignee_id' }) {
return axios.get(`${this.url}/grouped_conversation_metrics`, {
params: { group_by: groupBy },
});
}
}
export default new LiveReportsAPI();
+2 -2
View File
@@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient {
});
}
getConversationTrafficCSV() {
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset() },
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
});
}
@@ -29,6 +29,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
labelClass: {
type: String,
default: '',
},
});
const emit = defineEmits(['action']);
@@ -97,9 +101,13 @@ onMounted(() => {
</slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
item.label
}}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="filteredMenuItems.length === 0"
@@ -42,6 +42,8 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const globalConfig = useMapGetter('globalConfig/get');
const showV4Routes = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
@@ -525,7 +527,12 @@ const menuItems = computed(() => {
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
<div class="grid flex-shrink-0 size-6 place-content-center">
<Logo />
<img
v-if="globalConfig.logoThumbnail"
:src="globalConfig.logoThumbnail"
class="h-5 w-5"
/>
<Logo v-else />
</div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher
@@ -0,0 +1,28 @@
import { ref, onBeforeUnmount } from 'vue';
export const useLiveRefresh = (callback, interval = 60000) => {
const timeoutId = ref(null);
const startRefetching = () => {
timeoutId.value = setTimeout(async () => {
await callback();
startRefetching();
}, interval);
};
const stopRefetching = () => {
if (timeoutId.value) {
clearTimeout(timeoutId.value);
timeoutId.value = null;
}
};
onBeforeUnmount(() => {
stopRefetching();
});
return {
startRefetching,
stopRefetching,
};
};
@@ -476,6 +476,18 @@
"STATUS": "Status"
}
},
"TEAM_CONVERSATIONS": {
"ALL_TEAMS": "All Teams",
"HEADER": "Conversations by teams",
"LOADING_MESSAGE": "Loading team metrics...",
"NO_TEAMS": "There is no data available",
"TABLE_HEADER": {
"TEAM": "Team",
"OPEN": "Open",
"UNATTENDED": "Unattended",
"STATUS": "Status"
}
},
"AGENT_STATUS": {
"HEADER": "Agent status",
"ONLINE": "Online",
@@ -1,214 +1,17 @@
<script>
import { mapGetters } from 'vuex';
import AgentTable from './components/overview/AgentTable.vue';
import MetricCard from './components/overview/MetricCard.vue';
import { OVERVIEW_METRICS } from './constants';
import ReportHeatmap from './components/Heatmap.vue';
import endOfDay from 'date-fns/endOfDay';
import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay';
import subDays from 'date-fns/subDays';
<script setup>
import ReportHeader from './components/ReportHeader.vue';
export const FETCH_INTERVAL = 60000;
export default {
name: 'LiveReports',
components: {
ReportHeader,
AgentTable,
MetricCard,
ReportHeatmap,
},
data() {
return {
// always start with 0, this is to manage the pagination in tanstack table
// when we send the data, we do a +1 to this value
pageIndex: 0,
};
},
computed: {
...mapGetters({
agentStatus: 'agents/getAgentStatus',
agents: 'agents/getAgents',
accountConversationMetric: 'getAccountConversationMetric',
agentConversationMetric: 'getAgentConversationMetric',
accountConversationHeatmap: 'getAccountConversationHeatmapData',
uiFlags: 'getOverviewUIFlags',
}),
agentStatusMetrics() {
let metric = {};
Object.keys(this.agentStatus).forEach(key => {
const metricName = this.$t(
`OVERVIEW_REPORTS.AGENT_STATUS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = this.agentStatus[key];
});
return metric;
},
conversationMetrics() {
let metric = {};
Object.keys(this.accountConversationMetric).forEach(key => {
const metricName = this.$t(
`OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = this.accountConversationMetric[key];
});
return metric;
},
},
mounted() {
this.$store.dispatch('agents/get');
this.initalizeReport();
},
beforeUnmount() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
},
methods: {
initalizeReport() {
this.fetchAllData();
this.scheduleReportRefresh();
},
scheduleReportRefresh() {
this.timeoutId = setTimeout(async () => {
await this.fetchAllData();
this.scheduleReportRefresh();
}, FETCH_INTERVAL);
},
fetchAllData() {
this.fetchAccountConversationMetric();
this.fetchAgentConversationMetric();
this.fetchHeatmapData();
},
downloadHeatmapData() {
let to = endOfDay(new Date());
this.$store.dispatch('downloadAccountConversationHeatmap', {
to: getUnixTime(to),
});
},
fetchHeatmapData() {
if (this.uiFlags.isFetchingAccountConversationsHeatmap) {
return;
}
// the data for the last 6 days won't ever change,
// so there's no need to fetch it again
// but we can write some logic to check if the data is already there
// if it is there, we can refetch data only for today all over again
// and reconcile it with the rest of the data
// this will reduce the load on the server doing number crunching
let to = endOfDay(new Date());
let from = startOfDay(subDays(to, 6));
if (this.accountConversationHeatmap.length) {
to = endOfDay(new Date());
from = startOfDay(to);
}
this.$store.dispatch('fetchAccountConversationHeatmap', {
metric: 'conversations_count',
from: getUnixTime(from),
to: getUnixTime(to),
groupBy: 'hour',
businessHours: false,
});
},
fetchAccountConversationMetric() {
this.$store.dispatch('fetchAccountConversationMetric', {
type: 'account',
});
},
fetchAgentConversationMetric() {
this.$store.dispatch('fetchAgentConversationMetric', {
type: 'agent',
page: this.pageIndex + 1,
});
},
onPageNumberChange(pageIndex) {
this.pageIndex = pageIndex;
this.fetchAgentConversationMetric();
},
},
};
import HeatmapContainer from './components/HeatmapContainer.vue';
import AgentLiveReportContainer from './components/AgentLiveReportContainer.vue';
import TeamLiveReportContainer from './components/TeamLiveReportContainer.vue';
import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vue';
</script>
<template>
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
<div class="flex flex-col gap-4 pb-6">
<div class="flex flex-col items-center md:flex-row gap-4">
<div
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
>
<MetricCard
:header="$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.HEADER')"
:is-loading="uiFlags.isFetchingAccountConversationMetric"
:loading-message="
$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.LOADING_MESSAGE')
"
>
<div
v-for="(metric, name, index) in conversationMetrics"
:key="index"
class="flex-1 min-w-0 pb-2"
>
<h3 class="text-base text-n-slate-11">
{{ name }}
</h3>
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
{{ metric }}
</p>
</div>
</MetricCard>
</div>
<div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%]">
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
<div
v-for="(metric, name, index) in agentStatusMetrics"
:key="index"
class="flex-1 min-w-0 pb-2"
>
<h3 class="text-base text-n-slate-11">
{{ name }}
</h3>
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
{{ metric }}
</p>
</div>
</MetricCard>
</div>
</div>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
<template #control>
<woot-button
icon="arrow-download"
size="small"
variant="smooth"
color-scheme="secondary"
@click="downloadHeatmapData"
>
{{ $t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT') }}
</woot-button>
</template>
<ReportHeatmap
:heat-data="accountConversationHeatmap"
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
/>
</MetricCard>
</div>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
<AgentTable
:agents="agents"
:agent-metrics="agentConversationMetric"
:page-index="pageIndex"
:is-loading="uiFlags.isFetchingAgentConversationMetric"
@page-change="onPageNumberChange"
/>
</MetricCard>
</div>
<StatsLiveReportsContainer />
<HeatmapContainer />
<AgentLiveReportContainer />
<TeamLiveReportContainer />
</div>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { onMounted } from 'vue';
import AgentTable from './overview/AgentTable.vue';
import MetricCard from './overview/MetricCard.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
const store = useStore();
const uiFlags = useMapGetter('getOverviewUIFlags');
const agentConversationMetric = useMapGetter('getAgentConversationMetric');
const agents = useMapGetter('agents/getAgents');
const fetchData = () => store.dispatch('fetchAgentConversationMetric');
const { startRefetching } = useLiveRefresh(fetchData);
onMounted(() => {
store.dispatch('agents/get');
fetchData();
startRefetching();
});
</script>
<template>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
<AgentTable
:agents="agents"
:agent-metrics="agentConversationMetric"
:is-loading="uiFlags.isFetchingAgentConversationMetric"
/>
</MetricCard>
</div>
</template>
@@ -10,10 +10,14 @@ import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
import { useI18n } from 'vue-i18n';
const props = defineProps({
heatData: {
heatmapData: {
type: Array,
default: () => [],
},
numberOfRows: {
type: Number,
default: 7,
},
isLoading: {
type: Boolean,
default: false,
@@ -21,11 +25,11 @@ const props = defineProps({
});
const { t } = useI18n();
const processedData = computed(() => {
return groupHeatmapByDay(props.heatData);
return groupHeatmapByDay(props.heatmapData);
});
const quantileRange = computed(() => {
const flattendedData = props.heatData.map(data => data.value);
const flattendedData = props.heatmapData.map(data => data.value);
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
});
@@ -95,14 +99,14 @@ function getHeatmapLevelClass(value) {
<template v-if="isLoading">
<div class="grid gap-[5px] flex-shrink-0">
<div
v-for="ii in 7"
v-for="ii in numberOfRows"
:key="ii"
class="w-full rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse h-8 min-w-[70px]"
/>
</div>
<div class="grid gap-[5px] w-full min-w-[700px]">
<div
v-for="ii in 7"
v-for="ii in numberOfRows"
:key="ii"
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
>
@@ -0,0 +1,119 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useToggle } from '@vueuse/core';
import MetricCard from './overview/MetricCard.vue';
import ReportHeatmap from './Heatmap.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import endOfDay from 'date-fns/endOfDay';
import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay';
import subDays from 'date-fns/subDays';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
const store = useStore();
const uiFlags = useMapGetter('getOverviewUIFlags');
const accountConversationHeatmap = useMapGetter(
'getAccountConversationHeatmapData'
);
const { t } = useI18n();
const menuItems = [
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
value: 6,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
value: 29,
},
];
const selectedDays = ref(6);
const selectedDayFilter = computed(() =>
menuItems.find(menuItem => menuItem.value === selectedDays.value)
);
const downloadHeatmapData = () => {
const to = endOfDay(new Date());
store.dispatch('downloadAccountConversationHeatmap', {
daysBefore: selectedDays.value,
to: getUnixTime(to),
});
};
const [showDropdown, toggleDropdown] = useToggle();
const fetchHeatmapData = () => {
if (uiFlags.value.isFetchingAccountConversationsHeatmap) {
return;
}
let to = endOfDay(new Date());
let from = startOfDay(subDays(to, Number(selectedDays.value)));
store.dispatch('fetchAccountConversationHeatmap', {
metric: 'conversations_count',
from: getUnixTime(from),
to: getUnixTime(to),
groupBy: 'hour',
businessHours: false,
});
};
const handleAction = ({ value }) => {
toggleDropdown(false);
selectedDays.value = value;
fetchHeatmapData();
};
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
onMounted(() => {
fetchHeatmapData();
startRefetching();
});
</script>
<template>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
<template #control>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
:label="selectedDayFilter.label"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
<Button
sm
slate
faded
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
class="rounded-md group-hover:bg-n-alpha-2"
@click="downloadHeatmapData"
/>
</template>
<ReportHeatmap
:heatmap-data="accountConversationHeatmap"
:number-of-rows="selectedDays + 1"
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
/>
</MetricCard>
</div>
</template>
@@ -0,0 +1,142 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { OVERVIEW_METRICS } from '../constants';
import { useToggle } from '@vueuse/core';
import MetricCard from './overview/MetricCard.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const uiFlags = useMapGetter('getOverviewUIFlags');
const agentStatus = useMapGetter('agents/getAgentStatus');
const accountConversationMetric = useMapGetter('getAccountConversationMetric');
const store = useStore();
const accounti18nKey = 'OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS';
const teams = useMapGetter('teams/getTeams');
const teamMenuList = computed(() => {
return [
{ label: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.ALL_TEAMS'), value: null },
...teams.value.map(team => ({ label: team.name, value: team.id })),
];
});
const agentStatusMetrics = computed(() => {
let metric = {};
Object.keys(agentStatus.value).forEach(key => {
const metricName = t(
`OVERVIEW_REPORTS.AGENT_STATUS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = agentStatus.value[key];
});
return metric;
});
const conversationMetrics = computed(() => {
let metric = {};
Object.keys(accountConversationMetric.value).forEach(key => {
const metricName = t(`${accounti18nKey}.${OVERVIEW_METRICS[key]}`);
metric[metricName] = accountConversationMetric.value[key];
});
return metric;
});
const selectedTeam = ref(null);
const selectedTeamLabel = computed(() => {
const team =
teamMenuList.value.find(
menuItem => menuItem.value === selectedTeam.value
) || {};
return team.label;
});
const fetchData = () => {
const params = {};
if (selectedTeam.value) {
params.team_id = selectedTeam.value;
}
store.dispatch('fetchAccountConversationMetric', params);
};
const { startRefetching } = useLiveRefresh(fetchData);
const [showDropdown, toggleDropdown] = useToggle();
const handleAction = ({ value }) => {
toggleDropdown(false);
selectedTeam.value = value;
fetchData();
};
onMounted(() => {
fetchData();
startRefetching();
});
</script>
<template>
<div class="flex flex-col items-center md:flex-row gap-4">
<div
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
>
<MetricCard
:header="t(`${accounti18nKey}.HEADER`)"
:is-loading="uiFlags.isFetchingAccountConversationMetric"
:loading-message="t(`${accounti18nKey}.LOADING_MESSAGE`)"
>
<template v-if="teams.length" #control>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group z-50"
>
<Button
sm
slate
faded
:label="selectedTeamLabel"
class="capitalize rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="teamMenuList"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
label-class="capitalize"
@action="handleAction($event)"
/>
</div>
</template>
<div
v-for="(metric, name, index) in conversationMetrics"
:key="index"
class="flex-1 min-w-0 pb-2"
>
<h3 class="text-base text-n-slate-11">
{{ name }}
</h3>
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
{{ metric }}
</p>
</div>
</MetricCard>
</div>
<div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%]">
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
<div
v-for="(metric, name, index) in agentStatusMetrics"
:key="index"
class="flex-1 min-w-0 pb-2"
>
<h3 class="text-base text-n-slate-11">
{{ name }}
</h3>
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
{{ metric }}
</p>
</div>
</MetricCard>
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { onMounted } from 'vue';
import MetricCard from './overview/MetricCard.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import TeamTable from './overview/TeamTable.vue';
const store = useStore();
const uiFlags = useMapGetter('getOverviewUIFlags');
const teamConversationMetric = useMapGetter('getTeamConversationMetric');
const teams = useMapGetter('teams/getTeams');
const fetchData = () => store.dispatch('fetchTeamConversationMetric');
const { startRefetching } = useLiveRefresh(fetchData);
onMounted(() => {
store.dispatch('teams/get');
fetchData();
startRefetching();
});
</script>
<template>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.HEADER')">
<TeamTable
:teams="teams"
:team-metrics="teamConversationMetric"
:is-loading="uiFlags.isFetchingTeamConversationMetric"
/>
</MetricCard>
</div>
</template>
@@ -4,6 +4,7 @@ import {
useVueTable,
createColumnHelper,
getCoreRowModel,
getPaginationRowModel,
} from '@tanstack/vue-table';
import { useI18n } from 'vue-i18n';
@@ -13,7 +14,7 @@ import Table from 'dashboard/components/table/Table.vue';
import Pagination from 'dashboard/components/table/Pagination.vue';
import AgentCell from './AgentCell.vue';
const { agents, agentMetrics, pageIndex } = defineProps({
const { agents, agentMetrics } = defineProps({
agents: {
type: Array,
default: () => [],
@@ -26,42 +27,45 @@ const { agents, agentMetrics, pageIndex } = defineProps({
type: Boolean,
default: false,
},
pageIndex: {
type: Number,
default: 1,
},
});
const emit = defineEmits(['pageChange']);
const { t } = useI18n();
function getAgentInformation(id) {
return agents?.find(agent => agent.id === Number(id));
}
const getAgentMetrics = id =>
agentMetrics.find(metrics => metrics.assignee_id === Number(id)) || {};
const totalCount = computed(() => agents.length);
const tableData = computed(() => {
return agentMetrics
.filter(agentMetric => getAgentInformation(agentMetric.id))
const tableData = computed(() =>
agents
.map(agent => {
const agentInformation = getAgentInformation(agent.id);
const metric = getAgentMetrics(agent.id);
return {
agent: agentInformation.name || agentInformation.available_name,
email: agentInformation.email,
thumbnail: agentInformation.thumbnail,
open: agent.metric.open ?? 0,
unattended: agent.metric.unattended ?? 0,
status: agentInformation.availability_status,
agent: agent.available_name || agent.name,
email: agent.email,
thumbnail: agent.thumbnail,
open: metric.open || 0,
unattended: metric.unattended || 0,
status: agent.availability_status,
};
});
});
})
.sort((a, b) => {
// First sort by open tickets (descending)
const openDiff = b.open - a.open;
// If open tickets are equal, sort by name (ascending)
if (openDiff === 0) {
return a.agent.localeCompare(b.agent);
}
return openDiff;
})
);
const defaulSpanRender = cellProps =>
h(
'span',
{
class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
class: cellProps.getValue()
? 'capitalize text-n-slate-12'
: 'capitalize text-n-slate-11',
},
cellProps.getValue() ? cellProps.getValue() : '---'
);
@@ -86,100 +90,33 @@ const columns = [
}),
];
const paginationParams = computed(() => {
return {
pageIndex: pageIndex,
pageSize: 25,
};
});
const table = useVueTable({
get data() {
return tableData.value;
},
columns,
manualPagination: true,
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
get rowCount() {
return totalCount.value;
},
state: {
get pagination() {
return paginationParams.value;
},
},
onPaginationChange: updater => {
const newPagintaion = updater(paginationParams.value);
emit('pageChange', newPagintaion.pageIndex);
},
getPaginationRowModel: getPaginationRowModel(),
});
</script>
<template>
<div class="agent-table-container">
<div class="flex flex-col flex-1">
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
<Pagination class="mt-2" :table="table" />
<div v-if="isLoading" class="agents-loader">
<div
v-if="isLoading"
class="items-center flex text-base justify-center p-8"
>
<Spinner />
<span>{{
$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE')
}}</span>
<span>
{{ $t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE') }}
</span>
</div>
<EmptyState
v-else-if="!isLoading && !agentMetrics.length"
v-else-if="!isLoading && !agents.length"
:title="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.NO_AGENTS')"
/>
</div>
</template>
<style lang="scss" scoped>
.agent-table-container {
@apply flex flex-col flex-1;
.ve-table {
&::v-deep {
th.ve-table-header-th {
@apply text-sm rounded-xl;
padding: var(--space-small) var(--space-two) !important;
}
td.ve-table-body-td {
padding: var(--space-one) var(--space-two) !important;
}
}
}
&::v-deep .ve-pagination {
@apply bg-transparent dark:bg-transparent;
}
&::v-deep .ve-pagination-select {
@apply hidden;
}
.row-user-block {
@apply items-center flex text-left;
.user-block {
@apply items-start flex flex-col min-w-0 my-0 mx-2;
.title {
@apply text-sm m-0 leading-[1.2] text-slate-800 dark:text-slate-100;
}
.sub-title {
@apply text-xs text-slate-600 dark:text-slate-200;
}
}
}
.table-pagination {
@apply mt-4 text-right;
}
}
.agents-loader {
@apply items-center flex text-base justify-center p-8;
}
</style>
@@ -1,31 +1,25 @@
<script>
<script setup>
import Spinner from 'shared/components/Spinner.vue';
export default {
name: 'MetricCard',
components: {
Spinner,
defineProps({
header: {
type: String,
default: '',
},
props: {
header: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
loadingMessage: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
};
loadingMessage: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="flex flex-col m-0.5 px-6 py-5 overflow-hidden rounded-xl flex-grow text-n-slate-12 shadow outline-1 outline outline-n-container bg-n-solid-2 min-h-[10rem]"
class="flex flex-col m-0.5 px-6 py-5 rounded-xl flex-grow text-n-slate-12 shadow outline-1 outline outline-n-container bg-n-solid-2 min-h-[10rem]"
>
<div
class="card-header grid w-full mb-6 grid-cols-[repeat(auto-fit,minmax(max-content,50%))] gap-y-2"
@@ -46,9 +40,7 @@ export default {
</span>
</span>
</div>
<div
class="transition-opacity duration-200 ease-in-out opacity-20 hover:opacity-100 flex flex-row items-center justify-end gap-2"
>
<div class="flex flex-row items-center justify-end gap-2">
<slot name="control" />
</div>
</slot>
@@ -0,0 +1,116 @@
<script setup>
import { computed, h } from 'vue';
import {
useVueTable,
createColumnHelper,
getCoreRowModel,
getPaginationRowModel,
} from '@tanstack/vue-table';
import { useI18n } from 'vue-i18n';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import Table from 'dashboard/components/table/Table.vue';
import Pagination from 'dashboard/components/table/Pagination.vue';
const { teams, teamMetrics } = defineProps({
teams: {
type: Array,
default: () => [],
},
teamMetrics: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const getTeamMetrics = id =>
teamMetrics.find(metrics => metrics.team_id === Number(id)) || {};
const tableData = computed(() =>
teams
.map(team => {
const metric = getTeamMetrics(team.id);
return {
agent: team.name,
open: metric.open || 0,
unattended: metric.unattended || 0,
};
})
.sort((a, b) => {
// First sort by open tickets (descending)
const openDiff = b.open - a.open;
// If open tickets are equal, sort by name (ascending)
if (openDiff === 0) {
return a.agent.localeCompare(b.agent);
}
return openDiff;
})
);
const defaulSpanRender = cellProps =>
h(
'span',
{
class: cellProps.getValue()
? 'capitalize text-n-slate-12'
: 'capitalize text-n-slate-11',
},
cellProps.getValue() ? cellProps.getValue() : '---'
);
const columnHelper = createColumnHelper();
const columns = [
columnHelper.accessor('agent', {
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.TEAM'),
cell: defaulSpanRender,
size: 250,
}),
columnHelper.accessor('open', {
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.OPEN'),
cell: defaulSpanRender,
size: 100,
}),
columnHelper.accessor('unattended', {
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.UNATTENDED'),
cell: defaulSpanRender,
size: 100,
}),
];
const table = useVueTable({
get data() {
return tableData.value;
},
columns,
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
</script>
<template>
<div class="flex flex-col flex-1">
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
<Pagination class="mt-2" :table="table" />
<div
v-if="isLoading"
class="items-center flex text-base justify-center p-8"
>
<Spinner />
<span>
{{ $t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.LOADING_MESSAGE') }}
</span>
</div>
<EmptyState
v-else-if="!isLoading && !teams.length"
:title="$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.NO_TEAMS')"
/>
</div>
</template>
@@ -4,10 +4,8 @@ import Report from '../../api/reports';
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
import {
reconcileHeatmapData,
clampDataBetweenTimeline,
} from 'shared/helpers/ReportsDataHelper';
import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper';
import liveReports from '../../api/liveReports';
const state = {
fetchingStatus: false,
@@ -57,10 +55,12 @@ const state = {
isFetchingAccountConversationMetric: false,
isFetchingAccountConversationsHeatmap: false,
isFetchingAgentConversationMetric: false,
isFetchingTeamConversationMetric: false,
},
accountConversationMetric: {},
accountConversationHeatmap: [],
agentConversationMetric: [],
teamConversationMetric: [],
},
};
@@ -83,6 +83,9 @@ const getters = {
getAgentConversationMetric(_state) {
return _state.overview.agentConversationMetric;
},
getTeamConversationMetric(_state) {
return _state.overview.teamConversationMetric;
},
getOverviewUIFlags($state) {
return $state.overview.uiFlags;
},
@@ -114,11 +117,6 @@ export const actions = {
let { data } = heatmapData;
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
data = reconcileHeatmapData(
data,
state.overview.accountConversationHeatmap
);
commit(types.default.SET_HEATMAP_DATA, data);
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
});
@@ -153,9 +151,10 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
});
},
fetchAccountConversationMetric({ commit }, reportObj) {
fetchAccountConversationMetric({ commit }, params = {}) {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type)
liveReports
.getConversationMetric(params)
.then(accountConversationMetric => {
commit(
types.default.SET_ACCOUNT_CONVERSATION_METRIC,
@@ -167,9 +166,10 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
});
},
fetchAgentConversationMetric({ commit }, reportObj) {
fetchAgentConversationMetric({ commit }) {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type, reportObj.page)
liveReports
.getGroupedConversations({ groupBy: 'assignee_id' })
.then(agentConversationMetric => {
commit(
types.default.SET_AGENT_CONVERSATION_METRIC,
@@ -181,6 +181,18 @@ export const actions = {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false);
});
},
fetchTeamConversationMetric({ commit }) {
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, true);
liveReports
.getGroupedConversations({ groupBy: 'team_id' })
.then(teamMetric => {
commit(types.default.SET_TEAM_CONVERSATION_METRIC, teamMetric.data);
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
})
.catch(() => {
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
});
},
downloadAgentReports(_, reportObj) {
return Report.getAgentReports(reportObj)
.then(response => {
@@ -234,7 +246,7 @@ export const actions = {
});
},
downloadAccountConversationHeatmap(_, reportObj) {
Report.getConversationTrafficCSV()
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
.then(response => {
downloadCsvFile(
generateFileName({
@@ -286,6 +298,12 @@ const mutations = {
[types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAgentConversationMetric = flag;
},
[types.default.SET_TEAM_CONVERSATION_METRIC](_state, metricData) {
_state.overview.teamConversationMetric = metricData;
},
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingTeamConversationMetric = flag;
},
};
export default {
@@ -335,4 +335,8 @@ export default {
SET_SLA_REPORTS: 'SET_SLA_REPORTS',
SET_SLA_REPORTS_METRICS: 'SET_SLA_REPORTS_METRICS',
SET_SLA_REPORTS_META: 'SET_SLA_REPORTS_META',
SET_TEAM_CONVERSATION_METRIC: 'SET_TEAM_CONVERSATION_METRIC',
TOGGLE_TEAM_CONVERSATION_METRIC_LOADING:
'TOGGLE_TEAM_CONVERSATION_METRIC_LOADING',
};
@@ -1,4 +1,4 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.agent_csv.agent_name'),
@@ -9,7 +9,7 @@
I18n.t('reports.agent_csv.resolution_count')
]
%>
<%= CSVSafe.generate_line headers -%>
<%= CSV.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<%= CSV.generate_line row -%>
<% end %>
@@ -1,5 +1,5 @@
<%= CSV.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<%= CSV.generate_line row -%>
<% end %>
@@ -1,4 +1,4 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.inbox_csv.inbox_name'),
@@ -8,7 +8,7 @@
I18n.t('reports.inbox_csv.avg_resolution_time')
]
%>
<%= CSVSafe.generate_line headers -%>
<%= CSV.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<%= CSV.generate_line row -%>
<% end %>
@@ -1,4 +1,4 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.label_csv.label_title'),
@@ -7,7 +7,7 @@
I18n.t('reports.label_csv.avg_resolution_time')
]
%>
<%= CSVSafe.generate_line headers -%>
<%= CSV.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<%= CSV.generate_line row -%>
<% end %>
@@ -1,4 +1,4 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.team_csv.team_name'),
@@ -9,7 +9,7 @@
I18n.t('reports.team_csv.resolution_count')
]
%>
<%= CSVSafe.generate_line headers -%>
<%= CSV.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<%= CSV.generate_line row -%>
<% end %>
+6
View File
@@ -336,6 +336,12 @@ Rails.application.routes.draw do
get :bot_metrics
end
end
resources :live_reports, only: [] do
collection do
get :conversation_metrics
get :grouped_conversation_metrics
end
end
end
end
end