Merge branch 'develop' into chore/load-reply-message
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,9 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { format } from 'date-fns';
|
||||
import sessionStorage from 'shared/helpers/sessionStorage';
|
||||
|
||||
import BillingMeter from './components/BillingMeter.vue';
|
||||
import BillingCard from './components/BillingCard.vue';
|
||||
@@ -13,7 +15,8 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
const { currentAccount } = useAccount();
|
||||
const router = useRouter();
|
||||
const { currentAccount, isOnChatwootCloud } = useAccount();
|
||||
const {
|
||||
captainEnabled,
|
||||
captainLimits,
|
||||
@@ -24,6 +27,12 @@ const {
|
||||
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
const store = useStore();
|
||||
|
||||
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
||||
|
||||
// State for handling refresh attempts and loading
|
||||
const isWaitingForBilling = ref(false);
|
||||
|
||||
const customAttributes = computed(() => {
|
||||
return currentAccount.value.custom_attributes || {};
|
||||
});
|
||||
@@ -61,11 +70,45 @@ const hasABillingPlan = computed(() => {
|
||||
|
||||
const fetchAccountDetails = async () => {
|
||||
if (!hasABillingPlan.value) {
|
||||
store.dispatch('accounts/subscription');
|
||||
await store.dispatch('accounts/subscription');
|
||||
fetchLimits();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingPageLogic = async () => {
|
||||
// If self-hosted, redirect to dashboard
|
||||
if (!isOnChatwootCloud.value) {
|
||||
router.push({ name: 'home' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've already attempted a refresh for billing setup
|
||||
const billingRefreshAttempted = sessionStorage.get(BILLING_REFRESH_ATTEMPTED);
|
||||
|
||||
// If cloud user, fetch account details first
|
||||
await fetchAccountDetails();
|
||||
|
||||
// If still no billing plan after fetch
|
||||
if (!hasABillingPlan.value) {
|
||||
// If we haven't attempted refresh yet, do it once
|
||||
if (!billingRefreshAttempted) {
|
||||
isWaitingForBilling.value = true;
|
||||
sessionStorage.set(BILLING_REFRESH_ATTEMPTED, true);
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 5000);
|
||||
} else {
|
||||
// We've already tried refreshing, so just show the no billing message
|
||||
// Clear the flag for future visits
|
||||
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
|
||||
}
|
||||
} else {
|
||||
// Billing plan found, clear any existing refresh flag
|
||||
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickBillingPortal = () => {
|
||||
store.dispatch('accounts/checkout');
|
||||
};
|
||||
@@ -76,14 +119,18 @@ const onToggleChatWindow = () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchAccountDetails);
|
||||
onMounted(handleBillingPageLogic);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.isFetchingItem"
|
||||
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
|
||||
:no-records-found="!hasABillingPlan"
|
||||
:is-loading="uiFlags.isFetchingItem || isWaitingForBilling"
|
||||
:loading-message="
|
||||
isWaitingForBilling
|
||||
? $t('BILLING_SETTINGS.NO_BILLING_USER')
|
||||
: $t('ATTRIBUTES_MGMT.LOADING')
|
||||
"
|
||||
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
|
||||
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
|
||||
>
|
||||
<template #header>
|
||||
|
||||
@@ -96,11 +96,12 @@ export default {
|
||||
return parse(this.toTime, 'hh:mm a', new Date());
|
||||
},
|
||||
totalHours() {
|
||||
if (this.timeSlot.openAllDay) {
|
||||
return 24;
|
||||
}
|
||||
const totalHours = differenceInMinutes(this.toDate, this.fromDate) / 60;
|
||||
return totalHours;
|
||||
if (this.timeSlot.openAllDay) return '24h';
|
||||
|
||||
const totalMinutes = differenceInMinutes(this.toDate, this.fromDate);
|
||||
const [h, m] = [Math.floor(totalMinutes / 60), totalMinutes % 60];
|
||||
|
||||
return [h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ') || '0m';
|
||||
},
|
||||
hasError() {
|
||||
return !this.timeSlot.valid;
|
||||
@@ -211,7 +212,7 @@ export default {
|
||||
v-if="isDayEnabled && !hasError"
|
||||
class="label bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text text-xs inline-block px-2 py-1 rounded-lg cursor-default whitespace-nowrap"
|
||||
>
|
||||
{{ totalHours }} {{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.HOURS') }}
|
||||
{{ totalHours }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,7 @@ export const generateTimeSlots = (step = 15) => {
|
||||
Generates a list of time strings from 12:00 AM to next 24 hours. Each new string
|
||||
will be generated by adding `step` minutes to the previous one.
|
||||
The list is generated by starting with a random day and adding step minutes till end of the same day.
|
||||
Always includes 11:59 PM as the final slot to complete the day.
|
||||
*/
|
||||
const date = new Date(1970, 1, 1);
|
||||
const slots = [];
|
||||
@@ -66,6 +67,13 @@ export const generateTimeSlots = (step = 15) => {
|
||||
);
|
||||
date.setMinutes(date.getMinutes() + step);
|
||||
}
|
||||
|
||||
// Always add 11:59 PM as the final slot if it's not already included
|
||||
const lastSlot = '11:59 PM';
|
||||
if (!slots.includes(lastSlot)) {
|
||||
slots.push(lastSlot);
|
||||
}
|
||||
|
||||
return slots;
|
||||
};
|
||||
|
||||
|
||||
+55
-3
@@ -7,10 +7,19 @@ import {
|
||||
} from '../businessHour';
|
||||
|
||||
describe('#generateTimeSlots', () => {
|
||||
it('returns correct number of time slots', () => {
|
||||
expect(generateTimeSlots(15).length).toStrictEqual((60 / 15) * 24);
|
||||
it('returns correct number of time slots for 15-minute intervals', () => {
|
||||
const slots = generateTimeSlots(15);
|
||||
// 24 hours * 4 slots per hour + 1 for 11:59 PM = 97 slots
|
||||
expect(slots.length).toStrictEqual(97);
|
||||
});
|
||||
it('returns correct time slots', () => {
|
||||
|
||||
it('returns correct number of time slots for 30-minute intervals', () => {
|
||||
const slots = generateTimeSlots(30);
|
||||
// 24 hours * 2 slots per hour + 1 for 11:59 PM = 49 slots
|
||||
expect(slots.length).toStrictEqual(49);
|
||||
});
|
||||
|
||||
it('returns correct time slots for 4-hour intervals', () => {
|
||||
expect(generateTimeSlots(240)).toStrictEqual([
|
||||
'12:00 AM',
|
||||
'04:00 AM',
|
||||
@@ -18,8 +27,51 @@ describe('#generateTimeSlots', () => {
|
||||
'12:00 PM',
|
||||
'04:00 PM',
|
||||
'08:00 PM',
|
||||
'11:59 PM',
|
||||
]);
|
||||
});
|
||||
|
||||
it('always starts with 12:00 AM', () => {
|
||||
expect(generateTimeSlots(15)[0]).toStrictEqual('12:00 AM');
|
||||
expect(generateTimeSlots(30)[0]).toStrictEqual('12:00 AM');
|
||||
expect(generateTimeSlots(60)[0]).toStrictEqual('12:00 AM');
|
||||
});
|
||||
|
||||
it('always ends with 11:59 PM', () => {
|
||||
const slots15 = generateTimeSlots(15);
|
||||
const slots30 = generateTimeSlots(30);
|
||||
const slots60 = generateTimeSlots(60);
|
||||
|
||||
expect(slots15[slots15.length - 1]).toStrictEqual('11:59 PM');
|
||||
expect(slots30[slots30.length - 1]).toStrictEqual('11:59 PM');
|
||||
expect(slots60[slots60.length - 1]).toStrictEqual('11:59 PM');
|
||||
});
|
||||
|
||||
it('includes 11:59 PM even when it would not be in regular intervals', () => {
|
||||
const slots = generateTimeSlots(30);
|
||||
expect(slots).toContain('11:59 PM');
|
||||
expect(slots).toContain('11:30 PM'); // Regular interval
|
||||
});
|
||||
|
||||
it('does not duplicate 11:59 PM if it already exists in regular intervals', () => {
|
||||
// Test with a step that would naturally include 11:59 PM
|
||||
const slots = generateTimeSlots(1); // 1-minute intervals
|
||||
const count11_59 = slots.filter(slot => slot === '11:59 PM').length;
|
||||
expect(count11_59).toStrictEqual(1);
|
||||
});
|
||||
|
||||
it('generates correct time format', () => {
|
||||
const slots = generateTimeSlots(60);
|
||||
expect(slots).toContain('01:00 AM');
|
||||
expect(slots).toContain('12:00 PM');
|
||||
expect(slots).toContain('01:00 PM');
|
||||
expect(slots).toContain('11:00 PM');
|
||||
});
|
||||
|
||||
it('handles edge case with very large step', () => {
|
||||
const slots = generateTimeSlots(1440); // 24 hours
|
||||
expect(slots).toStrictEqual(['12:00 AM', '11:59 PM']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getTime', () => {
|
||||
|
||||
@@ -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>
|
||||
-119
@@ -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>
|
||||
+214
@@ -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>
|
||||
+265
@@ -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>
|
||||
+18
@@ -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>
|
||||
+57
@@ -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>
|
||||
+18
@@ -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>
|
||||
+34
@@ -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 };
|
||||
}
|
||||
@@ -26,12 +26,12 @@ const fetchMetaData = async (commit, params) => {
|
||||
};
|
||||
|
||||
const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1500);
|
||||
const longDebouncedFetchMetaData = debounce(fetchMetaData, 1000, false, 8000);
|
||||
const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
|
||||
const superLongDebouncedFetchMetaData = debounce(
|
||||
fetchMetaData,
|
||||
1500,
|
||||
10000,
|
||||
false,
|
||||
10000
|
||||
20000
|
||||
);
|
||||
|
||||
export const actions = {
|
||||
|
||||
@@ -154,7 +154,10 @@ const equalTo = (filterValue, conversationValue) => {
|
||||
* It only works with string values and returns false for non-string types.
|
||||
*/
|
||||
const contains = (filterValue, conversationValue) => {
|
||||
if (typeof conversationValue === 'string') {
|
||||
if (
|
||||
typeof conversationValue === 'string' &&
|
||||
typeof filterValue === 'string'
|
||||
) {
|
||||
return conversationValue.toLowerCase().includes(filterValue.toLowerCase());
|
||||
}
|
||||
return false;
|
||||
@@ -190,10 +193,8 @@ const compareDates = (conversationValue, filterValue, compareFn) => {
|
||||
const matchesCondition = (conversationValue, filter) => {
|
||||
const { filter_operator: filterOperator, values } = filter;
|
||||
|
||||
// Handle null/undefined values
|
||||
if (conversationValue === null || conversationValue === undefined) {
|
||||
return filterOperator === 'is_not_present';
|
||||
}
|
||||
const isNullish =
|
||||
conversationValue === null || conversationValue === undefined;
|
||||
|
||||
const filterValue = Array.isArray(values)
|
||||
? values.map(resolveValue)
|
||||
@@ -213,10 +214,10 @@ const matchesCondition = (conversationValue, filter) => {
|
||||
return !contains(filterValue, conversationValue);
|
||||
|
||||
case 'is_present':
|
||||
return true; // We already handled null/undefined above
|
||||
return !isNullish;
|
||||
|
||||
case 'is_not_present':
|
||||
return false; // We already handled null/undefined above
|
||||
return isNullish;
|
||||
|
||||
case 'is_greater_than':
|
||||
return compareDates(conversationValue, filterValue, (a, b) => a > b);
|
||||
@@ -225,6 +226,10 @@ const matchesCondition = (conversationValue, filter) => {
|
||||
return compareDates(conversationValue, filterValue, (a, b) => a < b);
|
||||
|
||||
case 'days_before': {
|
||||
if (isNullish) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const daysInMilliseconds = filterValue * 24 * 60 * 60 * 1000;
|
||||
const targetDate = new Date(today.getTime() - daysInMilliseconds);
|
||||
|
||||
+78
@@ -192,6 +192,32 @@ describe('filterHelpers', () => {
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match conversation with equal_to operator when assignee is null', () => {
|
||||
const conversation = { meta: { assignee: null } };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'assignee_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: { id: 1, name: 'John Doe' },
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match conversation with not_equal_to operator when assignee is null', () => {
|
||||
const conversation = { meta: { assignee: null } };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'assignee_id',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: { id: 1, name: 'John Doe' },
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match conversation with is_not_present operator for assignee_id', () => {
|
||||
const conversation = { meta: { assignee: null } };
|
||||
const filters = [
|
||||
@@ -285,6 +311,58 @@ describe('filterHelpers', () => {
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match contains operator when display_id is null', () => {
|
||||
const conversation = { id: null };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'display_id',
|
||||
filter_operator: 'contains',
|
||||
values: '234',
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match contains operator when filter value is null', () => {
|
||||
const conversation = { id: '12345' };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'display_id',
|
||||
filter_operator: 'contains',
|
||||
values: null,
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match does_not_contain operator when display_id is null', () => {
|
||||
const conversation = { id: null };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'display_id',
|
||||
filter_operator: 'does_not_contain',
|
||||
values: '234',
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match does_not_contain operator when filter value is null', () => {
|
||||
const conversation = { id: '12345' };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'display_id',
|
||||
filter_operator: 'does_not_contain',
|
||||
values: null,
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match conversation with does_not_contain operator when value is not present', () => {
|
||||
const conversation = { id: '12345' };
|
||||
const filters = [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -22,6 +22,8 @@ import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
|
||||
const ERROR_MESSAGES = {
|
||||
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
|
||||
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
|
||||
'saml-authentication-failed': 'LOGIN.SAML.API.ERROR_MESSAGE',
|
||||
'saml-not-enabled': 'LOGIN.SAML.API.ERROR_MESSAGE',
|
||||
};
|
||||
|
||||
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
|
||||
|
||||
@@ -15,6 +15,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
target: {
|
||||
type: String,
|
||||
default: 'web',
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
@@ -107,6 +111,7 @@ onMounted(async () => {
|
||||
name="authenticity_token"
|
||||
:value="csrfToken"
|
||||
/>
|
||||
<input type="hidden" class="h-0" name="target" :value="target" />
|
||||
<NextButton
|
||||
lg
|
||||
type="submit"
|
||||
|
||||
@@ -28,6 +28,7 @@ export default [
|
||||
meta: { requireEnterprise: true },
|
||||
props: route => ({
|
||||
authError: route.query.error,
|
||||
target: route.query.target,
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user