mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	Merge branch 'develop' into fix/extend-phone-normalization-to-twilio-whatsapp
This commit is contained in:
		| @@ -51,6 +51,7 @@ | ||||
|     }, | ||||
|     "DATE_RANGE_OPTIONS": { | ||||
|       "LAST_7_DAYS": "Last 7 days", | ||||
|       "LAST_14_DAYS": "Last 14 days", | ||||
|       "LAST_30_DAYS": "Last 30 days", | ||||
|       "LAST_3_MONTHS": "Last 3 months", | ||||
|       "LAST_6_MONTHS": "Last 6 months", | ||||
| @@ -266,6 +267,8 @@ | ||||
|     "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", | ||||
|     "DOWNLOAD_INBOX_REPORTS": "Download inbox reports", | ||||
|     "FILTER_DROPDOWN_LABEL": "Select Inbox", | ||||
|     "ALL_INBOXES": "All Inboxes", | ||||
|     "SEARCH_INBOX": "Search Inbox", | ||||
|     "METRICS": { | ||||
|       "CONVERSATIONS": { | ||||
|         "NAME": "Conversations", | ||||
| @@ -467,6 +470,13 @@ | ||||
|       "CONVERSATIONS": "{count} conversations", | ||||
|       "DOWNLOAD_REPORT": "Download report" | ||||
|     }, | ||||
|     "RESOLUTION_HEATMAP": { | ||||
|       "HEADER": "Resolutions", | ||||
|       "NO_CONVERSATIONS": "No conversations", | ||||
|       "CONVERSATION": "{count} conversation", | ||||
|       "CONVERSATIONS": "{count} conversations", | ||||
|       "DOWNLOAD_REPORT": "Download report" | ||||
|     }, | ||||
|     "AGENT_CONVERSATIONS": { | ||||
|       "HEADER": "Conversations by agents", | ||||
|       "LOADING_MESSAGE": "Loading agent metrics...", | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| <script setup> | ||||
| import ReportHeader from './components/ReportHeader.vue'; | ||||
| import HeatmapContainer from './components/HeatmapContainer.vue'; | ||||
| import ConversationHeatmapContainer from './components/heatmaps/ConversationHeatmapContainer.vue'; | ||||
| import ResolutionHeatmapContainer from './components/heatmaps/ResolutionHeatmapContainer.vue'; | ||||
| import AgentLiveReportContainer from './components/AgentLiveReportContainer.vue'; | ||||
| import TeamLiveReportContainer from './components/TeamLiveReportContainer.vue'; | ||||
| import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vue'; | ||||
| @@ -10,7 +11,8 @@ import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vu | ||||
|   <ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" /> | ||||
|   <div class="flex flex-col gap-4 pb-6"> | ||||
|     <StatsLiveReportsContainer /> | ||||
|     <HeatmapContainer /> | ||||
|     <ConversationHeatmapContainer /> | ||||
|     <ResolutionHeatmapContainer /> | ||||
|     <AgentLiveReportContainer /> | ||||
|     <TeamLiveReportContainer /> | ||||
|   </div> | ||||
|   | ||||
| @@ -1,175 +0,0 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue'; | ||||
|  | ||||
| import format from 'date-fns/format'; | ||||
| import getDay from 'date-fns/getDay'; | ||||
|  | ||||
| import { getQuantileIntervals } from '@chatwoot/utils'; | ||||
|  | ||||
| import { groupHeatmapByDay } from 'helpers/ReportsDataHelper'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   heatmapData: { | ||||
|     type: Array, | ||||
|     default: () => [], | ||||
|   }, | ||||
|   numberOfRows: { | ||||
|     type: Number, | ||||
|     default: 7, | ||||
|   }, | ||||
|   isLoading: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
| }); | ||||
| const { t } = useI18n(); | ||||
| const processedData = computed(() => { | ||||
|   return groupHeatmapByDay(props.heatmapData); | ||||
| }); | ||||
|  | ||||
| const quantileRange = computed(() => { | ||||
|   const flattendedData = props.heatmapData.map(data => data.value); | ||||
|   return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]); | ||||
| }); | ||||
|  | ||||
| function getCountTooltip(value) { | ||||
|   if (!value) { | ||||
|     return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS'); | ||||
|   } | ||||
|  | ||||
|   if (value === 1) { | ||||
|     return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', { | ||||
|       count: value, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', { | ||||
|     count: value, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function formatDate(dateString) { | ||||
|   return format(new Date(dateString), 'MMM d, yyyy'); | ||||
| } | ||||
|  | ||||
| function getDayOfTheWeek(date) { | ||||
|   const dayIndex = getDay(date); | ||||
|   const days = [ | ||||
|     t('DAYS_OF_WEEK.SUNDAY'), | ||||
|     t('DAYS_OF_WEEK.MONDAY'), | ||||
|     t('DAYS_OF_WEEK.TUESDAY'), | ||||
|     t('DAYS_OF_WEEK.WEDNESDAY'), | ||||
|     t('DAYS_OF_WEEK.THURSDAY'), | ||||
|     t('DAYS_OF_WEEK.FRIDAY'), | ||||
|     t('DAYS_OF_WEEK.SATURDAY'), | ||||
|   ]; | ||||
|   return days[dayIndex]; | ||||
| } | ||||
| function getHeatmapLevelClass(value) { | ||||
|   if (!value) return 'outline-n-container bg-n-slate-2 dark:bg-n-slate-5/50'; | ||||
|  | ||||
|   let level = [...quantileRange.value, Infinity].findIndex( | ||||
|     range => value <= range && value > 0 | ||||
|   ); | ||||
|  | ||||
|   if (level > 6) level = 5; | ||||
|  | ||||
|   if (level === 0) { | ||||
|     return 'outline-n-container bg-n-slate-2 dark:bg-n-slate-5/50'; | ||||
|   } | ||||
|  | ||||
|   const classes = [ | ||||
|     'bg-n-blue-3 dark:outline-n-blue-4', | ||||
|     'bg-n-blue-5 dark:outline-n-blue-6', | ||||
|     'bg-n-blue-7 dark:outline-n-blue-8', | ||||
|     'bg-n-blue-8 dark:outline-n-blue-9', | ||||
|     'bg-n-blue-10 dark:outline-n-blue-8', | ||||
|     'bg-n-blue-11 dark:outline-n-blue-10', | ||||
|   ]; | ||||
|  | ||||
|   return classes[level - 1]; | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div | ||||
|     class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72" | ||||
|   > | ||||
|     <template v-if="isLoading"> | ||||
|       <div class="grid gap-[5px] flex-shrink-0"> | ||||
|         <div | ||||
|           v-for="ii in numberOfRows" | ||||
|           :key="ii" | ||||
|           class="w-full rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse h-8 min-w-[70px]" | ||||
|         /> | ||||
|       </div> | ||||
|       <div class="grid gap-[5px] w-full min-w-[700px]"> | ||||
|         <div | ||||
|           v-for="ii in numberOfRows" | ||||
|           :key="ii" | ||||
|           class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]" | ||||
|         > | ||||
|           <div | ||||
|             v-for="jj in 24" | ||||
|             :key="jj" | ||||
|             class="w-full h-8 rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div /> | ||||
|       <div | ||||
|         class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-11" | ||||
|       > | ||||
|         <div | ||||
|           v-for="ii in 24" | ||||
|           :key="ii" | ||||
|           class="flex items-center justify-center" | ||||
|         > | ||||
|           {{ ii - 1 }} – {{ ii }} | ||||
|         </div> | ||||
|       </div> | ||||
|     </template> | ||||
|     <template v-else> | ||||
|       <div class="grid gap-[5px] flex-shrink-0"> | ||||
|         <div | ||||
|           v-for="dateKey in processedData.keys()" | ||||
|           :key="dateKey" | ||||
|           class="h-8 min-w-[70px] text-n-slate-12 text-[10px] font-semibold flex flex-col items-end justify-center" | ||||
|         > | ||||
|           {{ getDayOfTheWeek(new Date(dateKey)) }} | ||||
|           <time class="font-normal text-n-slate-11"> | ||||
|             {{ formatDate(dateKey) }} | ||||
|           </time> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="grid gap-[5px] w-full min-w-[700px]"> | ||||
|         <div | ||||
|           v-for="dateKey in processedData.keys()" | ||||
|           :key="dateKey" | ||||
|           class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]" | ||||
|         > | ||||
|           <div | ||||
|             v-for="data in processedData.get(dateKey)" | ||||
|             :key="data.timestamp" | ||||
|             v-tooltip.top="getCountTooltip(data.value)" | ||||
|             class="h-8 rounded-sm shadow-inner dark:outline dark:outline-1" | ||||
|             :class="getHeatmapLevelClass(data.value)" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div /> | ||||
|       <div | ||||
|         class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-12" | ||||
|       > | ||||
|         <div | ||||
|           v-for="ii in 24" | ||||
|           :key="ii" | ||||
|           class="flex items-center justify-center" | ||||
|         > | ||||
|           {{ ii - 1 }} – {{ ii }} | ||||
|         </div> | ||||
|       </div> | ||||
|     </template> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -1,119 +0,0 @@ | ||||
| <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,214 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue'; | ||||
| import { useMemoize } from '@vueuse/core'; | ||||
|  | ||||
| import format from 'date-fns/format'; | ||||
| import getDay from 'date-fns/getDay'; | ||||
|  | ||||
| import { getQuantileIntervals } from '@chatwoot/utils'; | ||||
|  | ||||
| import { groupHeatmapByDay } from 'helpers/ReportsDataHelper'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { useHeatmapTooltip } from './composables/useHeatmapTooltip'; | ||||
| import HeatmapTooltip from './HeatmapTooltip.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   heatmapData: { | ||||
|     type: Array, | ||||
|     default: () => [], | ||||
|   }, | ||||
|   numberOfRows: { | ||||
|     type: Number, | ||||
|     default: 7, | ||||
|   }, | ||||
|   isLoading: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   colorScheme: { | ||||
|     type: String, | ||||
|     default: 'blue', | ||||
|     validator: value => ['blue', 'green'].includes(value), | ||||
|   }, | ||||
| }); | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const dataRows = computed(() => { | ||||
|   const groupedData = groupHeatmapByDay(props.heatmapData); | ||||
|   return Array.from(groupedData.keys()).map(dateKey => { | ||||
|     const rowData = groupedData.get(dateKey); | ||||
|     return { | ||||
|       dateKey, | ||||
|       data: rowData, | ||||
|       dataHash: rowData.map(d => d.value).join(','), | ||||
|     }; | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| const quantileRange = computed(() => { | ||||
|   const flattendedData = props.heatmapData.map(data => data.value); | ||||
|   return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]); | ||||
| }); | ||||
|  | ||||
| function formatDate(dateString) { | ||||
|   return format(new Date(dateString), 'MMM d, yyyy'); | ||||
| } | ||||
|  | ||||
| const DAYS_OF_WEEK = [ | ||||
|   t('DAYS_OF_WEEK.SUNDAY'), | ||||
|   t('DAYS_OF_WEEK.MONDAY'), | ||||
|   t('DAYS_OF_WEEK.TUESDAY'), | ||||
|   t('DAYS_OF_WEEK.WEDNESDAY'), | ||||
|   t('DAYS_OF_WEEK.THURSDAY'), | ||||
|   t('DAYS_OF_WEEK.FRIDAY'), | ||||
|   t('DAYS_OF_WEEK.SATURDAY'), | ||||
| ]; | ||||
|  | ||||
| function getDayOfTheWeek(date) { | ||||
|   const dayIndex = getDay(date); | ||||
|  | ||||
|   return DAYS_OF_WEEK[dayIndex]; | ||||
| } | ||||
|  | ||||
| const COLOR_SCHEMES = { | ||||
|   blue: [ | ||||
|     'bg-n-blue-3 border border-n-blue-4/30', | ||||
|     'bg-n-blue-5 border border-n-blue-6/30', | ||||
|     'bg-n-blue-7 border border-n-blue-8/30', | ||||
|     'bg-n-blue-8 border border-n-blue-9/30', | ||||
|     'bg-n-blue-10 border border-n-blue-8/30', | ||||
|     'bg-n-blue-11 border border-n-blue-10/30', | ||||
|   ], | ||||
|   green: [ | ||||
|     'bg-n-teal-3 border border-n-teal-4/30', | ||||
|     'bg-n-teal-5 border border-n-teal-6/30', | ||||
|     'bg-n-teal-7 border border-n-teal-8/30', | ||||
|     'bg-n-teal-8 border border-n-teal-9/30', | ||||
|     'bg-n-teal-10 border border-n-teal-8/30', | ||||
|     'bg-n-teal-11 border border-n-teal-10/30', | ||||
|   ], | ||||
| }; | ||||
|  | ||||
| // Memoized function to calculate CSS class for heatmap cell intensity levels | ||||
| const getHeatmapLevelClass = useMemoize( | ||||
|   (value, quantileRangeArray, colorScheme) => { | ||||
|     if (!value) | ||||
|       return 'border border-n-container bg-n-slate-2 dark:bg-n-slate-1/30'; | ||||
|     let level = [...quantileRangeArray, Infinity].findIndex( | ||||
|       range => value <= range && value > 0 | ||||
|     ); | ||||
|  | ||||
|     if (level > 6) level = 5; | ||||
|  | ||||
|     if (level === 0) { | ||||
|       return 'border border-n-container bg-n-slate-2 dark:bg-n-slate-1/30'; | ||||
|     } | ||||
|  | ||||
|     return COLOR_SCHEMES[colorScheme][level - 1]; | ||||
|   } | ||||
| ); | ||||
|  | ||||
| function getHeatmapClass(value) { | ||||
|   return getHeatmapLevelClass(value, quantileRange.value, props.colorScheme); | ||||
| } | ||||
|  | ||||
| // Tooltip composable | ||||
| const tooltip = useHeatmapTooltip(); | ||||
| </script> | ||||
|  | ||||
| <!-- eslint-disable vue/no-static-inline-styles --> | ||||
| <template> | ||||
|   <div | ||||
|     class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72" | ||||
|   > | ||||
|     <template v-if="isLoading"> | ||||
|       <div class="grid gap-[5px] flex-shrink-0"> | ||||
|         <div | ||||
|           v-for="ii in numberOfRows" | ||||
|           :key="ii" | ||||
|           class="w-full rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse h-8 min-w-[70px]" | ||||
|         /> | ||||
|       </div> | ||||
|       <div class="grid gap-[5px] w-full min-w-[700px]"> | ||||
|         <div | ||||
|           v-for="ii in numberOfRows" | ||||
|           :key="ii" | ||||
|           class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]" | ||||
|         > | ||||
|           <div | ||||
|             v-for="jj in 24" | ||||
|             :key="jj" | ||||
|             class="w-full h-8 rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div /> | ||||
|       <div | ||||
|         class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-11" | ||||
|       > | ||||
|         <div | ||||
|           v-for="ii in 24" | ||||
|           :key="ii" | ||||
|           class="flex items-center justify-center" | ||||
|         > | ||||
|           {{ ii - 1 }} | ||||
|         </div> | ||||
|       </div> | ||||
|     </template> | ||||
|     <template v-else> | ||||
|       <div class="grid gap-[5px] flex-shrink-0"> | ||||
|         <div | ||||
|           v-for="row in dataRows" | ||||
|           :key="row.dateKey" | ||||
|           v-memo="[row.dateKey]" | ||||
|           class="h-8 min-w-[70px] text-n-slate-12 text-[10px] font-semibold flex flex-col items-end justify-center" | ||||
|         > | ||||
|           {{ getDayOfTheWeek(new Date(row.dateKey)) }} | ||||
|           <time class="font-normal text-n-slate-11"> | ||||
|             {{ formatDate(row.dateKey) }} | ||||
|           </time> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div | ||||
|         class="grid gap-[5px] w-full min-w-[700px]" | ||||
|         style="content-visibility: auto" | ||||
|       > | ||||
|         <div | ||||
|           v-for="row in dataRows" | ||||
|           :key="row.dateKey" | ||||
|           v-memo="[row.dataHash, colorScheme]" | ||||
|           class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]" | ||||
|           style="content-visibility: auto" | ||||
|         > | ||||
|           <div | ||||
|             v-for="data in row.data" | ||||
|             :key="data.timestamp" | ||||
|             class="h-8 rounded-sm cursor-pointer" | ||||
|             :class="getHeatmapClass(data.value)" | ||||
|             @mouseenter="tooltip.show($event, data.value)" | ||||
|             @mouseleave="tooltip.hide" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div /> | ||||
|       <div | ||||
|         class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-12" | ||||
|       > | ||||
|         <div | ||||
|           v-for="ii in 24" | ||||
|           :key="ii" | ||||
|           class="flex items-center justify-center" | ||||
|         > | ||||
|           {{ ii - 1 }} | ||||
|         </div> | ||||
|       </div> | ||||
|     </template> | ||||
|  | ||||
|     <HeatmapTooltip | ||||
|       :visible="tooltip.visible.value" | ||||
|       :x="tooltip.x.value" | ||||
|       :y="tooltip.y.value" | ||||
|       :value="tooltip.value.value" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -0,0 +1,265 @@ | ||||
| <script setup> | ||||
| import { onMounted, ref, computed } from 'vue'; | ||||
| import { useToggle } from '@vueuse/core'; | ||||
| import MetricCard from '../overview/MetricCard.vue'; | ||||
| import BaseHeatmap from './BaseHeatmap.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 format from 'date-fns/format'; | ||||
| import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { downloadCsvFile } from 'dashboard/helper/downloadHelper'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   metric: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   title: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   downloadTitle: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   storeGetter: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   storeAction: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   downloadAction: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   uiFlagKey: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   colorScheme: { | ||||
|     type: String, | ||||
|     default: 'blue', | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const store = useStore(); | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const uiFlags = useMapGetter('getOverviewUIFlags'); | ||||
| const heatmapData = useMapGetter(props.storeGetter); | ||||
| const inboxes = useMapGetter('inboxes/getInboxes'); | ||||
|  | ||||
| const menuItems = [ | ||||
|   { | ||||
|     label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'), | ||||
|     value: 6, | ||||
|   }, | ||||
|   { | ||||
|     label: t('REPORT.DATE_RANGE_OPTIONS.LAST_14_DAYS'), | ||||
|     value: 13, | ||||
|   }, | ||||
|   { | ||||
|     label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'), | ||||
|     value: 29, | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| const selectedDays = ref(6); | ||||
| const selectedInbox = ref(null); | ||||
|  | ||||
| const selectedDayFilter = computed(() => | ||||
|   menuItems.find(menuItem => menuItem.value === selectedDays.value) | ||||
| ); | ||||
|  | ||||
| const inboxMenuItems = computed(() => { | ||||
|   return [ | ||||
|     { | ||||
|       label: t('INBOX_REPORTS.ALL_INBOXES'), | ||||
|       value: null, | ||||
|       action: 'select_inbox', | ||||
|     }, | ||||
|     ...inboxes.value.map(inbox => ({ | ||||
|       label: inbox.name, | ||||
|       value: inbox.id, | ||||
|       action: 'select_inbox', | ||||
|     })), | ||||
|   ]; | ||||
| }); | ||||
|  | ||||
| const selectedInboxFilter = computed(() => { | ||||
|   if (!selectedInbox.value) { | ||||
|     return { label: t('INBOX_REPORTS.ALL_INBOXES') }; | ||||
|   } | ||||
|   return inboxMenuItems.value.find( | ||||
|     item => item.value === selectedInbox.value.id | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| const isLoading = computed(() => uiFlags.value[props.uiFlagKey]); | ||||
|  | ||||
| const downloadHeatmapData = () => { | ||||
|   const to = endOfDay(new Date()); | ||||
|  | ||||
|   // If no inbox is selected and download action exists, use backend endpoint | ||||
|   if (!selectedInbox.value && props.downloadAction) { | ||||
|     store.dispatch(props.downloadAction, { | ||||
|       daysBefore: selectedDays.value, | ||||
|       to: getUnixTime(to), | ||||
|     }); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   // Generate CSV from store data | ||||
|   if (!heatmapData.value || heatmapData.value.length === 0) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   // Create CSV headers | ||||
|   const headers = ['Date', 'Hour', props.title]; | ||||
|   const rows = [headers]; | ||||
|  | ||||
|   // Convert heatmap data to rows | ||||
|   heatmapData.value.forEach(item => { | ||||
|     const date = new Date(item.timestamp * 1000); | ||||
|     const dateStr = format(date, 'yyyy-MM-dd'); | ||||
|     const hour = date.getHours(); | ||||
|     rows.push([dateStr, `${hour}:00 - ${hour + 1}:00`, item.value]); | ||||
|   }); | ||||
|  | ||||
|   // Convert to CSV string | ||||
|   const csvContent = rows.map(row => row.join(',')).join('\n'); | ||||
|  | ||||
|   // Generate filename | ||||
|   const inboxName = selectedInbox.value | ||||
|     ? `_${selectedInbox.value.name.replace(/[^a-z0-9]/gi, '_')}` | ||||
|     : ''; | ||||
|   const fileName = `${props.downloadTitle}${inboxName}_${format( | ||||
|     new Date(), | ||||
|     'dd-MM-yyyy' | ||||
|   )}.csv`; | ||||
|  | ||||
|   // Download the file | ||||
|   downloadCsvFile(fileName, csvContent); | ||||
| }; | ||||
|  | ||||
| const [showDropdown, toggleDropdown] = useToggle(); | ||||
| const [showInboxDropdown, toggleInboxDropdown] = useToggle(); | ||||
|  | ||||
| const fetchHeatmapData = () => { | ||||
|   if (isLoading.value) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   let to = endOfDay(new Date()); | ||||
|   let from = startOfDay(subDays(to, Number(selectedDays.value))); | ||||
|  | ||||
|   const params = { | ||||
|     metric: props.metric, | ||||
|     from: getUnixTime(from), | ||||
|     to: getUnixTime(to), | ||||
|     groupBy: 'hour', | ||||
|     businessHours: false, | ||||
|   }; | ||||
|  | ||||
|   // Add inbox filtering if an inbox is selected | ||||
|   if (selectedInbox.value) { | ||||
|     params.type = 'inbox'; | ||||
|     params.id = selectedInbox.value.id; | ||||
|   } | ||||
|  | ||||
|   store.dispatch(props.storeAction, params); | ||||
| }; | ||||
|  | ||||
| const handleAction = ({ value }) => { | ||||
|   toggleDropdown(false); | ||||
|   selectedDays.value = value; | ||||
|   fetchHeatmapData(); | ||||
| }; | ||||
|  | ||||
| const handleInboxAction = ({ value }) => { | ||||
|   toggleInboxDropdown(false); | ||||
|   selectedInbox.value = value | ||||
|     ? inboxes.value.find(inbox => inbox.id === value) | ||||
|     : null; | ||||
|   fetchHeatmapData(); | ||||
| }; | ||||
|  | ||||
| const { startRefetching } = useLiveRefresh(fetchHeatmapData); | ||||
|  | ||||
| onMounted(() => { | ||||
|   store.dispatch('inboxes/get'); | ||||
|   fetchHeatmapData(); | ||||
|   startRefetching(); | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div class="flex flex-row flex-wrap max-w-full"> | ||||
|     <MetricCard :header="title"> | ||||
|       <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> | ||||
|         <div | ||||
|           v-on-clickaway="() => toggleInboxDropdown(false)" | ||||
|           class="relative flex items-center group" | ||||
|         > | ||||
|           <Button | ||||
|             sm | ||||
|             slate | ||||
|             faded | ||||
|             :label="selectedInboxFilter.label" | ||||
|             class="rounded-md group-hover:bg-n-alpha-2 max-w-[200px]" | ||||
|             @click="toggleInboxDropdown()" | ||||
|           /> | ||||
|           <DropdownMenu | ||||
|             v-if="showInboxDropdown" | ||||
|             :menu-items="inboxMenuItems" | ||||
|             show-search | ||||
|             :search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')" | ||||
|             class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full min-w-[200px]" | ||||
|             @action="handleInboxAction($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> | ||||
|       <BaseHeatmap | ||||
|         :heatmap-data="heatmapData" | ||||
|         :number-of-rows="selectedDays + 1" | ||||
|         :is-loading="isLoading" | ||||
|         :color-scheme="colorScheme" | ||||
|       /> | ||||
|     </MetricCard> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -0,0 +1,18 @@ | ||||
| <script setup> | ||||
| import BaseHeatmapContainer from './BaseHeatmapContainer.vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
|  | ||||
| const { t } = useI18n(); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <BaseHeatmapContainer | ||||
|     metric="conversations_count" | ||||
|     :title="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')" | ||||
|     download-title="conversation_heatmap" | ||||
|     store-getter="getAccountConversationHeatmapData" | ||||
|     store-action="fetchAccountConversationHeatmap" | ||||
|     download-action="downloadAccountConversationHeatmap" | ||||
|     ui-flag-key="isFetchingAccountConversationsHeatmap" | ||||
|   /> | ||||
| </template> | ||||
| @@ -0,0 +1,57 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   visible: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   x: { | ||||
|     type: Number, | ||||
|     default: 0, | ||||
|   }, | ||||
|   y: { | ||||
|     type: Number, | ||||
|     default: 0, | ||||
|   }, | ||||
|   value: { | ||||
|     type: Number, | ||||
|     default: null, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const tooltipText = computed(() => { | ||||
|   if (!props.value) { | ||||
|     return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS'); | ||||
|   } | ||||
|  | ||||
|   if (props.value === 1) { | ||||
|     return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', { | ||||
|       count: props.value, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', { | ||||
|     count: props.value, | ||||
|   }); | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <!-- eslint-disable vue/no-static-inline-styles --> | ||||
| <template> | ||||
|   <div | ||||
|     class="fixed z-50 px-2 py-1 text-xs font-medium text-n-slate-6 bg-n-slate-12 rounded shadow-lg pointer-events-none transition-[opacity,transform] duration-75" | ||||
|     :class="{ 'opacity-100': visible, 'opacity-0': !visible }" | ||||
|     :style="{ | ||||
|       left: `${x}px`, | ||||
|       top: `${y - 15}px`, | ||||
|       transform: 'translateX(-50%) translateZ(0)', | ||||
|       willChange: 'transform, opacity', | ||||
|     }" | ||||
|   > | ||||
|     {{ tooltipText }} | ||||
|   </div> | ||||
| </template> | ||||
| @@ -0,0 +1,18 @@ | ||||
| <script setup> | ||||
| import BaseHeatmapContainer from './BaseHeatmapContainer.vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
|  | ||||
| const { t } = useI18n(); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <BaseHeatmapContainer | ||||
|     metric="resolutions_count" | ||||
|     :title="t('OVERVIEW_REPORTS.RESOLUTION_HEATMAP.HEADER')" | ||||
|     download-title="resolution_heatmap" | ||||
|     store-getter="getAccountResolutionHeatmapData" | ||||
|     store-action="fetchAccountResolutionHeatmap" | ||||
|     ui-flag-key="isFetchingAccountResolutionsHeatmap" | ||||
|     color-scheme="green" | ||||
|   /> | ||||
| </template> | ||||
| @@ -0,0 +1,34 @@ | ||||
| import { ref } from 'vue'; | ||||
|  | ||||
| export function useHeatmapTooltip() { | ||||
|   const visible = ref(false); | ||||
|   const x = ref(0); | ||||
|   const y = ref(0); | ||||
|   const value = ref(null); | ||||
|  | ||||
|   let timeoutId = null; | ||||
|  | ||||
|   const show = (event, cellValue) => { | ||||
|     clearTimeout(timeoutId); | ||||
|  | ||||
|     // Update position immediately for smooth movement | ||||
|     const rect = event.target.getBoundingClientRect(); | ||||
|     x.value = rect.left + rect.width / 2; | ||||
|     y.value = rect.top; | ||||
|  | ||||
|     // Only delay content update and visibility | ||||
|     timeoutId = setTimeout(() => { | ||||
|       value.value = cellValue; | ||||
|       visible.value = true; | ||||
|     }, 100); | ||||
|   }; | ||||
|  | ||||
|   const hide = () => { | ||||
|     clearTimeout(timeoutId); | ||||
|     timeoutId = setTimeout(() => { | ||||
|       visible.value = false; | ||||
|     }, 50); | ||||
|   }; | ||||
|  | ||||
|   return { visible, x, y, value, show, hide }; | ||||
| } | ||||
| @@ -57,11 +57,13 @@ const state = { | ||||
|     uiFlags: { | ||||
|       isFetchingAccountConversationMetric: false, | ||||
|       isFetchingAccountConversationsHeatmap: false, | ||||
|       isFetchingAccountResolutionsHeatmap: false, | ||||
|       isFetchingAgentConversationMetric: false, | ||||
|       isFetchingTeamConversationMetric: false, | ||||
|     }, | ||||
|     accountConversationMetric: {}, | ||||
|     accountConversationHeatmap: [], | ||||
|     accountResolutionHeatmap: [], | ||||
|     agentConversationMetric: [], | ||||
|     teamConversationMetric: [], | ||||
|   }, | ||||
| @@ -89,6 +91,9 @@ const getters = { | ||||
|   getAccountConversationHeatmapData(_state) { | ||||
|     return _state.overview.accountConversationHeatmap; | ||||
|   }, | ||||
|   getAccountResolutionHeatmapData(_state) { | ||||
|     return _state.overview.accountResolutionHeatmap; | ||||
|   }, | ||||
|   getAgentConversationMetric(_state) { | ||||
|     return _state.overview.agentConversationMetric; | ||||
|   }, | ||||
| @@ -130,6 +135,16 @@ export const actions = { | ||||
|       commit(types.default.TOGGLE_HEATMAP_LOADING, false); | ||||
|     }); | ||||
|   }, | ||||
|   fetchAccountResolutionHeatmap({ commit }, reportObj) { | ||||
|     commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, true); | ||||
|     Report.getReports({ ...reportObj, groupBy: 'hour' }).then(heatmapData => { | ||||
|       let { data } = heatmapData; | ||||
|       data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to); | ||||
|  | ||||
|       commit(types.default.SET_RESOLUTION_HEATMAP_DATA, data); | ||||
|       commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, false); | ||||
|     }); | ||||
|   }, | ||||
|   fetchAccountSummary({ commit }, reportObj) { | ||||
|     commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING); | ||||
|     Report.getSummary( | ||||
| @@ -287,6 +302,9 @@ const mutations = { | ||||
|   [types.default.SET_HEATMAP_DATA](_state, heatmapData) { | ||||
|     _state.overview.accountConversationHeatmap = heatmapData; | ||||
|   }, | ||||
|   [types.default.SET_RESOLUTION_HEATMAP_DATA](_state, heatmapData) { | ||||
|     _state.overview.accountResolutionHeatmap = heatmapData; | ||||
|   }, | ||||
|   [types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) { | ||||
|     _state.accountReport.isFetching[metric] = value; | ||||
|   }, | ||||
| @@ -299,6 +317,9 @@ const mutations = { | ||||
|   [types.default.TOGGLE_HEATMAP_LOADING](_state, flag) { | ||||
|     _state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag; | ||||
|   }, | ||||
|   [types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING](_state, flag) { | ||||
|     _state.overview.uiFlags.isFetchingAccountResolutionsHeatmap = flag; | ||||
|   }, | ||||
|   [types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) { | ||||
|     _state.accountSummary = summaryData; | ||||
|   }, | ||||
|   | ||||
| @@ -187,6 +187,8 @@ export default { | ||||
|   SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS', | ||||
|   SET_HEATMAP_DATA: 'SET_HEATMAP_DATA', | ||||
|   TOGGLE_HEATMAP_LOADING: 'TOGGLE_HEATMAP_LOADING', | ||||
|   SET_RESOLUTION_HEATMAP_DATA: 'SET_RESOLUTION_HEATMAP_DATA', | ||||
|   TOGGLE_RESOLUTION_HEATMAP_LOADING: 'TOGGLE_RESOLUTION_HEATMAP_LOADING', | ||||
|   SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY', | ||||
|   SET_BOT_SUMMARY: 'SET_BOT_SUMMARY', | ||||
|   TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING', | ||||
|   | ||||
| @@ -16,8 +16,11 @@ class Seeders::Reports::ConversationCreator | ||||
|     @priorities = [nil, 'urgent', 'high', 'medium', 'low'] | ||||
|   end | ||||
|  | ||||
|   # rubocop:disable Metrics/MethodLength | ||||
|   def create_conversation(created_at:) | ||||
|     conversation = nil | ||||
|     should_resolve = false | ||||
|     resolution_time = nil | ||||
|  | ||||
|     ActiveRecord::Base.transaction do | ||||
|       travel_to(created_at) do | ||||
| @@ -26,14 +29,35 @@ class Seeders::Reports::ConversationCreator | ||||
|  | ||||
|         add_labels_to_conversation(conversation) | ||||
|         create_messages_for_conversation(conversation) | ||||
|         resolve_conversation_if_needed(conversation) | ||||
|  | ||||
|         # Determine if should resolve but don't update yet | ||||
|         should_resolve = rand > 0.3 | ||||
|         if should_resolve | ||||
|           resolution_delay = rand((30.minutes)..(24.hours)) | ||||
|           resolution_time = created_at + resolution_delay | ||||
|         end | ||||
|       end | ||||
|  | ||||
|       travel_back | ||||
|     end | ||||
|  | ||||
|     # Now resolve outside of time travel if needed | ||||
|     if should_resolve && resolution_time | ||||
|       # rubocop:disable Rails/SkipsModelValidations | ||||
|       conversation.update_column(:status, :resolved) | ||||
|       conversation.update_column(:updated_at, resolution_time) | ||||
|       # rubocop:enable Rails/SkipsModelValidations | ||||
|  | ||||
|       # Trigger the event with proper timestamp | ||||
|       travel_to(resolution_time) do | ||||
|         trigger_conversation_resolved_event(conversation) | ||||
|       end | ||||
|       travel_back | ||||
|     end | ||||
|  | ||||
|     conversation | ||||
|   end | ||||
|   # rubocop:enable Metrics/MethodLength | ||||
|  | ||||
|   private | ||||
|  | ||||
| @@ -85,16 +109,6 @@ class Seeders::Reports::ConversationCreator | ||||
|     message_creator.create_messages | ||||
|   end | ||||
|  | ||||
|   def resolve_conversation_if_needed(conversation) | ||||
|     return unless rand < 0.7 | ||||
|  | ||||
|     resolution_delay = rand((30.minutes)..(24.hours)) | ||||
|     travel(resolution_delay) | ||||
|     conversation.update!(status: :resolved) | ||||
|  | ||||
|     trigger_conversation_resolved_event(conversation) | ||||
|   end | ||||
|  | ||||
|   def trigger_conversation_resolved_event(conversation) | ||||
|     event_data = { conversation: conversation } | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Muhsin Keloth
					Muhsin Keloth