mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +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, | ||||
|     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" | ||||
|   | ||||
| @@ -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,166 +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 endOfDay from 'date-fns/endOfDay'; | ||||
| import getUnixTime from 'date-fns/getUnixTime'; | ||||
| <script setup> | ||||
| import ReportHeader from './components/ReportHeader.vue'; | ||||
| import HeatmapContainer from './components/HeatmapContainer.vue'; | ||||
| export const FETCH_INTERVAL = 60000; | ||||
|  | ||||
| export default { | ||||
|   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(); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| 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> | ||||
|     <StatsLiveReportsContainer /> | ||||
|     <HeatmapContainer /> | ||||
|     <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> | ||||
|     <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> | ||||
| @@ -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> | ||||
|   | ||||
| @@ -19,7 +19,7 @@ defineProps({ | ||||
|  | ||||
| <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" | ||||
|   | ||||
| @@ -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 { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events'; | ||||
| import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper'; | ||||
| import liveReports from '../../api/liveReports'; | ||||
|  | ||||
| const state = { | ||||
|   fetchingStatus: false, | ||||
| @@ -54,10 +55,12 @@ const state = { | ||||
|       isFetchingAccountConversationMetric: false, | ||||
|       isFetchingAccountConversationsHeatmap: false, | ||||
|       isFetchingAgentConversationMetric: false, | ||||
|       isFetchingTeamConversationMetric: false, | ||||
|     }, | ||||
|     accountConversationMetric: {}, | ||||
|     accountConversationHeatmap: [], | ||||
|     agentConversationMetric: [], | ||||
|     teamConversationMetric: [], | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| @@ -80,6 +83,9 @@ const getters = { | ||||
|   getAgentConversationMetric(_state) { | ||||
|     return _state.overview.agentConversationMetric; | ||||
|   }, | ||||
|   getTeamConversationMetric(_state) { | ||||
|     return _state.overview.teamConversationMetric; | ||||
|   }, | ||||
|   getOverviewUIFlags($state) { | ||||
|     return $state.overview.uiFlags; | ||||
|   }, | ||||
| @@ -145,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, | ||||
| @@ -159,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, | ||||
| @@ -173,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 => { | ||||
| @@ -278,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 { | ||||
|   | ||||
| @@ -338,4 +338,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', | ||||
| }; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Pranav
					Pranav