mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	feat: Add live report for teams (#10849)
This commit is contained in:
		
							
								
								
									
										20
									
								
								app/javascript/dashboard/api/liveReports.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								app/javascript/dashboard/api/liveReports.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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(); | ||||||
| @@ -29,6 +29,10 @@ const props = defineProps({ | |||||||
|     type: Boolean, |     type: Boolean, | ||||||
|     default: false, |     default: false, | ||||||
|   }, |   }, | ||||||
|  |   labelClass: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['action']); | const emit = defineEmits(['action']); | ||||||
| @@ -97,9 +101,13 @@ onMounted(() => { | |||||||
|       </slot> |       </slot> | ||||||
|       <Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" /> |       <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.emoji" class="flex-shrink-0">{{ item.emoji }}</span> | ||||||
|       <span v-if="item.label" class="min-w-0 text-sm truncate">{{ |       <span | ||||||
|         item.label |         v-if="item.label" | ||||||
|       }}</span> |         class="min-w-0 text-sm truncate" | ||||||
|  |         :class="labelClass" | ||||||
|  |       > | ||||||
|  |         {{ item.label }} | ||||||
|  |       </span> | ||||||
|     </button> |     </button> | ||||||
|     <div |     <div | ||||||
|       v-if="filteredMenuItems.length === 0" |       v-if="filteredMenuItems.length === 0" | ||||||
|   | |||||||
| @@ -476,6 +476,18 @@ | |||||||
|         "STATUS": "Status" |         "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": { |     "AGENT_STATUS": { | ||||||
|       "HEADER": "Agent status", |       "HEADER": "Agent status", | ||||||
|       "ONLINE": "Online", |       "ONLINE": "Online", | ||||||
|   | |||||||
| @@ -1,166 +1,17 @@ | |||||||
| <script> | <script setup> | ||||||
| import { mapGetters } from 'vuex'; |  | ||||||
| import AgentTable from './components/overview/AgentTable.vue'; |  | ||||||
| import MetricCard from './components/overview/MetricCard.vue'; |  | ||||||
| import { OVERVIEW_METRICS } from './constants'; |  | ||||||
|  |  | ||||||
| import endOfDay from 'date-fns/endOfDay'; |  | ||||||
| import getUnixTime from 'date-fns/getUnixTime'; |  | ||||||
| import ReportHeader from './components/ReportHeader.vue'; | import ReportHeader from './components/ReportHeader.vue'; | ||||||
| import HeatmapContainer from './components/HeatmapContainer.vue'; | import HeatmapContainer from './components/HeatmapContainer.vue'; | ||||||
| export const FETCH_INTERVAL = 60000; | import AgentLiveReportContainer from './components/AgentLiveReportContainer.vue'; | ||||||
|  | import TeamLiveReportContainer from './components/TeamLiveReportContainer.vue'; | ||||||
| export default { | import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vue'; | ||||||
|   name: 'LiveReports', |  | ||||||
|   components: { |  | ||||||
|     ReportHeader, |  | ||||||
|     AgentTable, |  | ||||||
|     MetricCard, |  | ||||||
|     HeatmapContainer, |  | ||||||
|   }, |  | ||||||
|   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', |  | ||||||
|       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(); |  | ||||||
|     }, |  | ||||||
|     downloadHeatmapData() { |  | ||||||
|       let to = endOfDay(new Date()); |  | ||||||
|  |  | ||||||
|       this.$store.dispatch('downloadAccountConversationHeatmap', { |  | ||||||
|         to: getUnixTime(to), |  | ||||||
|       }); |  | ||||||
|     }, |  | ||||||
|  |  | ||||||
|     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(); |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" /> |   <ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" /> | ||||||
|   <div class="flex flex-col gap-4 pb-6"> |   <div class="flex flex-col gap-4 pb-6"> | ||||||
|     <div class="flex flex-col items-center md:flex-row gap-4"> |     <StatsLiveReportsContainer /> | ||||||
|       <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> |  | ||||||
|     <HeatmapContainer /> |     <HeatmapContainer /> | ||||||
|     <div class="flex flex-row flex-wrap max-w-full"> |     <AgentLiveReportContainer /> | ||||||
|       <MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')"> |     <TeamLiveReportContainer /> | ||||||
|         <AgentTable |  | ||||||
|           :agents="agents" |  | ||||||
|           :agent-metrics="agentConversationMetric" |  | ||||||
|           :page-index="pageIndex" |  | ||||||
|           :is-loading="uiFlags.isFetchingAgentConversationMetric" |  | ||||||
|           @page-change="onPageNumberChange" |  | ||||||
|         /> |  | ||||||
|       </MetricCard> |  | ||||||
|     </div> |  | ||||||
|   </div> |   </div> | ||||||
| </template> | </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> | ||||||
| @@ -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, |   useVueTable, | ||||||
|   createColumnHelper, |   createColumnHelper, | ||||||
|   getCoreRowModel, |   getCoreRowModel, | ||||||
|  |   getPaginationRowModel, | ||||||
| } from '@tanstack/vue-table'; | } from '@tanstack/vue-table'; | ||||||
| import { useI18n } from 'vue-i18n'; | 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 Pagination from 'dashboard/components/table/Pagination.vue'; | ||||||
| import AgentCell from './AgentCell.vue'; | import AgentCell from './AgentCell.vue'; | ||||||
|  |  | ||||||
| const { agents, agentMetrics, pageIndex } = defineProps({ | const { agents, agentMetrics } = defineProps({ | ||||||
|   agents: { |   agents: { | ||||||
|     type: Array, |     type: Array, | ||||||
|     default: () => [], |     default: () => [], | ||||||
| @@ -26,42 +27,45 @@ const { agents, agentMetrics, pageIndex } = defineProps({ | |||||||
|     type: Boolean, |     type: Boolean, | ||||||
|     default: false, |     default: false, | ||||||
|   }, |   }, | ||||||
|   pageIndex: { |  | ||||||
|     type: Number, |  | ||||||
|     default: 1, |  | ||||||
|   }, |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['pageChange']); |  | ||||||
| const { t } = useI18n(); | const { t } = useI18n(); | ||||||
|  |  | ||||||
| function getAgentInformation(id) { | const getAgentMetrics = id => | ||||||
|   return agents?.find(agent => agent.id === Number(id)); |   agentMetrics.find(metrics => metrics.assignee_id === Number(id)) || {}; | ||||||
| } |  | ||||||
|  |  | ||||||
| const totalCount = computed(() => agents.length); | const tableData = computed(() => | ||||||
|  |   agents | ||||||
| const tableData = computed(() => { |  | ||||||
|   return agentMetrics |  | ||||||
|     .filter(agentMetric => getAgentInformation(agentMetric.id)) |  | ||||||
|     .map(agent => { |     .map(agent => { | ||||||
|       const agentInformation = getAgentInformation(agent.id); |       const metric = getAgentMetrics(agent.id); | ||||||
|       return { |       return { | ||||||
|         agent: agentInformation.name || agentInformation.available_name, |         agent: agent.available_name || agent.name, | ||||||
|         email: agentInformation.email, |         email: agent.email, | ||||||
|         thumbnail: agentInformation.thumbnail, |         thumbnail: agent.thumbnail, | ||||||
|         open: agent.metric.open ?? 0, |         open: metric.open || 0, | ||||||
|         unattended: agent.metric.unattended ?? 0, |         unattended: metric.unattended || 0, | ||||||
|         status: agentInformation.availability_status, |         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 => | const defaulSpanRender = cellProps => | ||||||
|   h( |   h( | ||||||
|     'span', |     '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() : '---' |     cellProps.getValue() ? cellProps.getValue() : '---' | ||||||
|   ); |   ); | ||||||
| @@ -86,100 +90,33 @@ const columns = [ | |||||||
|   }), |   }), | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| const paginationParams = computed(() => { |  | ||||||
|   return { |  | ||||||
|     pageIndex: pageIndex, |  | ||||||
|     pageSize: 25, |  | ||||||
|   }; |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| const table = useVueTable({ | const table = useVueTable({ | ||||||
|   get data() { |   get data() { | ||||||
|     return tableData.value; |     return tableData.value; | ||||||
|   }, |   }, | ||||||
|   columns, |   columns, | ||||||
|   manualPagination: true, |  | ||||||
|   enableSorting: false, |   enableSorting: false, | ||||||
|   getCoreRowModel: getCoreRowModel(), |   getCoreRowModel: getCoreRowModel(), | ||||||
|   get rowCount() { |   getPaginationRowModel: getPaginationRowModel(), | ||||||
|     return totalCount.value; |  | ||||||
|   }, |  | ||||||
|   state: { |  | ||||||
|     get pagination() { |  | ||||||
|       return paginationParams.value; |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   onPaginationChange: updater => { |  | ||||||
|     const newPagintaion = updater(paginationParams.value); |  | ||||||
|     emit('pageChange', newPagintaion.pageIndex); |  | ||||||
|   }, |  | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div class="agent-table-container"> |   <div class="flex flex-col flex-1"> | ||||||
|     <Table :table="table" class="max-h-[calc(100vh-21.875rem)]" /> |     <Table :table="table" class="max-h-[calc(100vh-21.875rem)]" /> | ||||||
|     <Pagination class="mt-2" :table="table" /> |     <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 /> |       <Spinner /> | ||||||
|       <span>{{ |       <span> | ||||||
|         $t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE') |         {{ $t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE') }} | ||||||
|       }}</span> |       </span> | ||||||
|     </div> |     </div> | ||||||
|     <EmptyState |     <EmptyState | ||||||
|       v-else-if="!isLoading && !agentMetrics.length" |       v-else-if="!isLoading && !agents.length" | ||||||
|       :title="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.NO_AGENTS')" |       :title="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.NO_AGENTS')" | ||||||
|     /> |     /> | ||||||
|   </div> |   </div> | ||||||
| </template> | </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> |  | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ defineProps({ | |||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div |   <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 |     <div | ||||||
|       class="card-header grid w-full mb-6 grid-cols-[repeat(auto-fit,minmax(max-content,50%))] gap-y-2" |       class="card-header grid w-full mb-6 grid-cols-[repeat(auto-fit,minmax(max-content,50%))] gap-y-2" | ||||||
|   | |||||||
| @@ -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> | ||||||
| @@ -5,6 +5,7 @@ import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper'; | |||||||
| import AnalyticsHelper from '../../helper/AnalyticsHelper'; | import AnalyticsHelper from '../../helper/AnalyticsHelper'; | ||||||
| import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events'; | import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events'; | ||||||
| import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper'; | import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper'; | ||||||
|  | import liveReports from '../../api/liveReports'; | ||||||
|  |  | ||||||
| const state = { | const state = { | ||||||
|   fetchingStatus: false, |   fetchingStatus: false, | ||||||
| @@ -54,10 +55,12 @@ const state = { | |||||||
|       isFetchingAccountConversationMetric: false, |       isFetchingAccountConversationMetric: false, | ||||||
|       isFetchingAccountConversationsHeatmap: false, |       isFetchingAccountConversationsHeatmap: false, | ||||||
|       isFetchingAgentConversationMetric: false, |       isFetchingAgentConversationMetric: false, | ||||||
|  |       isFetchingTeamConversationMetric: false, | ||||||
|     }, |     }, | ||||||
|     accountConversationMetric: {}, |     accountConversationMetric: {}, | ||||||
|     accountConversationHeatmap: [], |     accountConversationHeatmap: [], | ||||||
|     agentConversationMetric: [], |     agentConversationMetric: [], | ||||||
|  |     teamConversationMetric: [], | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -80,6 +83,9 @@ const getters = { | |||||||
|   getAgentConversationMetric(_state) { |   getAgentConversationMetric(_state) { | ||||||
|     return _state.overview.agentConversationMetric; |     return _state.overview.agentConversationMetric; | ||||||
|   }, |   }, | ||||||
|  |   getTeamConversationMetric(_state) { | ||||||
|  |     return _state.overview.teamConversationMetric; | ||||||
|  |   }, | ||||||
|   getOverviewUIFlags($state) { |   getOverviewUIFlags($state) { | ||||||
|     return $state.overview.uiFlags; |     return $state.overview.uiFlags; | ||||||
|   }, |   }, | ||||||
| @@ -145,9 +151,10 @@ export const actions = { | |||||||
|         commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false); |         commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false); | ||||||
|       }); |       }); | ||||||
|   }, |   }, | ||||||
|   fetchAccountConversationMetric({ commit }, reportObj) { |   fetchAccountConversationMetric({ commit }, params = {}) { | ||||||
|     commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true); |     commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true); | ||||||
|     Report.getConversationMetric(reportObj.type) |     liveReports | ||||||
|  |       .getConversationMetric(params) | ||||||
|       .then(accountConversationMetric => { |       .then(accountConversationMetric => { | ||||||
|         commit( |         commit( | ||||||
|           types.default.SET_ACCOUNT_CONVERSATION_METRIC, |           types.default.SET_ACCOUNT_CONVERSATION_METRIC, | ||||||
| @@ -159,9 +166,10 @@ export const actions = { | |||||||
|         commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false); |         commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false); | ||||||
|       }); |       }); | ||||||
|   }, |   }, | ||||||
|   fetchAgentConversationMetric({ commit }, reportObj) { |   fetchAgentConversationMetric({ commit }) { | ||||||
|     commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true); |     commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true); | ||||||
|     Report.getConversationMetric(reportObj.type, reportObj.page) |     liveReports | ||||||
|  |       .getGroupedConversations({ groupBy: 'assignee_id' }) | ||||||
|       .then(agentConversationMetric => { |       .then(agentConversationMetric => { | ||||||
|         commit( |         commit( | ||||||
|           types.default.SET_AGENT_CONVERSATION_METRIC, |           types.default.SET_AGENT_CONVERSATION_METRIC, | ||||||
| @@ -173,6 +181,18 @@ export const actions = { | |||||||
|         commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false); |         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) { |   downloadAgentReports(_, reportObj) { | ||||||
|     return Report.getAgentReports(reportObj) |     return Report.getAgentReports(reportObj) | ||||||
|       .then(response => { |       .then(response => { | ||||||
| @@ -278,6 +298,12 @@ const mutations = { | |||||||
|   [types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) { |   [types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) { | ||||||
|     _state.overview.uiFlags.isFetchingAgentConversationMetric = 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 { | export default { | ||||||
|   | |||||||
| @@ -338,4 +338,8 @@ export default { | |||||||
|   SET_SLA_REPORTS: 'SET_SLA_REPORTS', |   SET_SLA_REPORTS: 'SET_SLA_REPORTS', | ||||||
|   SET_SLA_REPORTS_METRICS: 'SET_SLA_REPORTS_METRICS', |   SET_SLA_REPORTS_METRICS: 'SET_SLA_REPORTS_METRICS', | ||||||
|   SET_SLA_REPORTS_META: 'SET_SLA_REPORTS_META', |   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', | ||||||
| }; | }; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Pranav
					Pranav