Merge branch 'develop' into feat/github-integration

This commit is contained in:
Muhsin Keloth
2025-10-28 19:27:09 +05:30
committed by GitHub
623 changed files with 34053 additions and 3965 deletions
@@ -2,6 +2,8 @@
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useRouter } from 'vue-router';
import { useAccount } from 'dashboard/composables/useAccount';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
@@ -11,7 +13,8 @@ import CreateAssistantDialog from 'dashboard/components-next/captain/pageCompone
import AssistantPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
import { useRouter } from 'vue-router';
const { isOnChatwootCloud } = useAccount();
const router = useRouter();
@@ -90,6 +93,7 @@ onMounted(() => store.dispatch('captainAssistants/get'));
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/assistant-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-popover-dark.svg"
learn-more-url="https://chwt.app/captain-assistant"
@@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
import CustomToolsIndex from './tools/Index.vue';
export const routes = [
{
@@ -124,4 +125,17 @@ export const routes = [
],
},
},
{
path: frontendURL('accounts/:accountId/captain/tools'),
component: CustomToolsIndex,
name: 'captain_tools_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
];
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
@@ -16,6 +17,7 @@ import LimitBanner from 'dashboard/components-next/captain/pageComponents/docume
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
const documents = useMapGetter('captainDocuments/getRecords');
const assistants = useMapGetter('captainAssistants/getRecords');
@@ -121,6 +123,7 @@ onMounted(() => {
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/document-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-popover-dark.svg"
learn-more-url="https://chwt.app/captain-document"
@@ -7,6 +7,7 @@ import { OnClickOutside } from '@vueuse/components';
import { useRouter } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
@@ -25,6 +26,7 @@ import LimitBanner from 'dashboard/components-next/captain/pageComponents/respon
const router = useRouter();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords');
const responseMeta = useMapGetter('captainResponses/getMeta');
@@ -285,6 +287,7 @@ onMounted(() => {
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
@@ -0,0 +1,138 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
const store = useStore();
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
const customTools = useMapGetter('captainCustomTools/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const customToolsMeta = useMapGetter('captainCustomTools/getMeta');
const createDialogRef = ref(null);
const deleteDialogRef = ref(null);
const selectedTool = ref(null);
const dialogType = ref('');
const fetchCustomTools = (page = 1) => {
store.dispatch('captainCustomTools/get', { page });
};
const onPageChange = page => fetchCustomTools(page);
const openCreateDialog = () => {
dialogType.value = 'create';
selectedTool.value = null;
nextTick(() => createDialogRef.value.dialogRef.open());
};
const handleEdit = tool => {
dialogType.value = 'edit';
selectedTool.value = tool;
nextTick(() => createDialogRef.value.dialogRef.open());
};
const handleDelete = tool => {
selectedTool.value = tool;
nextTick(() => deleteDialogRef.value.dialogRef.open());
};
const handleAction = ({ action, id }) => {
const tool = customTools.value.find(t => t.id === id);
if (action === 'edit') {
handleEdit(tool);
} else if (action === 'delete') {
handleDelete(tool);
}
};
const handleDialogClose = () => {
dialogType.value = '';
selectedTool.value = null;
};
const onDeleteSuccess = () => {
selectedTool.value = null;
// Check if page will be empty after deletion
if (customTools.value.length === 1 && customToolsMeta.value.page > 1) {
// Go to previous page if current page will be empty
onPageChange(customToolsMeta.value.page - 1);
} else {
// Refresh current page
fetchCustomTools(customToolsMeta.value.page);
}
};
onMounted(() => {
fetchCustomTools();
});
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.CUSTOM_TOOLS.HEADER')"
:button-label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
:button-policy="['administrator']"
:total-count="customToolsMeta.totalCount"
:current-page="customToolsMeta.page"
:show-pagination-footer="!isFetching && !!customTools.length"
:is-fetching="isFetching"
:is-empty="!customTools.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN_V2"
@update:current-page="onPageChange"
@click="openCreateDialog"
>
<template #paywall>
<CaptainPaywall />
</template>
<template #emptyState>
<CustomToolsPageEmptyState @click="openCreateDialog" />
</template>
<template #body>
<div class="flex flex-col gap-4">
<CustomToolCard
v-for="tool in customTools"
:id="tool.id"
:key="tool.id"
:title="tool.title"
:description="tool.description"
:endpoint-url="tool.endpoint_url"
:http-method="tool.http_method"
:auth-type="tool.auth_type"
:param-schema="tool.param_schema"
:enabled="tool.enabled"
:created-at="tool.created_at"
:updated-at="tool.updated_at"
@action="handleAction"
/>
</div>
</template>
</PageLayout>
<CreateCustomToolDialog
v-if="dialogType"
ref="createDialogRef"
:type="dialogType"
:selected-tool="selectedTool"
@close="handleDialogClose"
/>
<DeleteDialog
v-if="selectedTool"
ref="deleteDialogRef"
:entity="selectedTool"
type="CustomTools"
translation-key="CUSTOM_TOOLS"
@delete-success="onDeleteSuccess"
/>
</template>
@@ -21,7 +21,6 @@ import ShopifyOrdersList from 'dashboard/components/widgets/conversation/Shopify
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import LinearIssuesList from 'dashboard/components/widgets/conversation/linear/IssuesList.vue';
import LinearSetupCTA from 'dashboard/components/widgets/conversation/linear/LinearSetupCTA.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const props = defineProps({
conversationId: {
@@ -44,12 +43,6 @@ const {
const dragging = ref(false);
const conversationSidebarItems = ref([]);
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const shopifyIntegration = useFunctionGetter(
'integrations/getIntegration',
'shopify'
@@ -68,11 +61,6 @@ const isLinearIntegrationEnabled = computed(
() => linearIntegration.value?.enabled || false
);
const isLinearFeatureEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.LINEAR
);
const store = useStore();
const currentChat = useMapGetter('getSelectedChat');
const conversationId = computed(() => props.conversationId);
@@ -137,7 +125,7 @@ onMounted(() => {
@close="closeContactPanel"
/>
<ContactInfo :contact="contact" :channel-type="channelType" />
<div class="pb-8 list-group px-2">
<div class="px-2 pb-8 list-group">
<Draggable
:list="conversationSidebarItems"
animation="200"
@@ -250,11 +238,7 @@ onMounted(() => {
<MacrosList :conversation-id="conversationId" />
</AccordionItem>
</woot-feature-toggle>
<div
v-else-if="
element.name === 'linear_issues' && isLinearFeatureEnabled
"
>
<div v-else-if="element.name === 'linear_issues'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.LINEAR_ISSUES')"
:is-open="isContactSidebarItemOpen('is_linear_issues_open')"
@@ -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>
@@ -6,7 +6,6 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useBranding } from 'shared/composables/useBranding';
import PageHeader from '../SettingsSubPageHeader.vue';
import Icon from 'next/icon/Icon.vue';
const { t } = useI18n();
const route = useRoute();
@@ -14,22 +13,6 @@ const { replaceInstallationName } = useBranding();
const globalConfig = useMapGetter('globalConfig/get');
const ALL_CHANNEL_ICONS = [
'i-woot-line',
'i-woot-facebook',
'i-woot-whatsapp',
'i-woot-instagram',
'i-woot-messenger',
'i-woot-website',
'i-woot-mail',
'i-woot-sms',
'i-woot-telegram',
'i-woot-api',
'i-woot-twilio',
'i-woot-gmail',
'i-woot-outlook',
];
const createFlowSteps = computed(() => {
const steps = ['CHANNEL', 'INBOX', 'AGENT', 'FINISH'];
@@ -78,18 +61,6 @@ const items = computed(() => {
<template>
<div class="mx-2 flex flex-col gap-6 mb-8">
<PageHeader class="block lg:hidden !mb-0" :header-title="pageTitle" />
<div class="hidden lg:grid grid-cols-1 lg:grid-cols-8 items-center gap-2">
<div class="col-span-2 w-full" />
<div class="flex items-center gap-2 col-span-6 ltr:ml-8 rtl:mr-8">
<div
v-for="icon in ALL_CHANNEL_ICONS"
:key="icon"
class="size-8 bg-n-alpha-2 flex items-center flex-shrink-0 justify-center rounded-full"
>
<Icon :icon="icon" class="size-4 text-n-slate-10" />
</div>
</div>
</div>
<div
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[52rem]"
>
@@ -115,7 +115,7 @@ export default {
tabs() {
let visibleToAllChannelTabs = [
{
key: 'inbox_settings',
key: 'inbox-settings',
name: this.$t('INBOX_MGMT.TABS.SETTINGS'),
},
{
@@ -128,7 +128,7 @@ export default {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'businesshours',
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
@@ -142,11 +142,11 @@ export default {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'preChatForm',
key: 'pre-chat-form',
name: this.$t('INBOX_MGMT.TABS.PRE_CHAT_FORM'),
},
{
key: 'widgetBuilder',
key: 'widget-builder',
name: this.$t('INBOX_MGMT.TABS.WIDGET_BUILDER'),
},
];
@@ -176,7 +176,7 @@ export default {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'botConfiguration',
key: 'bot-configuration',
name: this.$t('INBOX_MGMT.TABS.BOT_CONFIGURATION'),
},
];
@@ -185,7 +185,7 @@ export default {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'whatsappHealth',
key: 'whatsapp-health',
name: this.$t('INBOX_MGMT.TABS.ACCOUNT_HEALTH'),
},
];
@@ -355,19 +355,39 @@ export default {
return [...selected, current];
},
refreshAvatarUrlOnTabChange(index) {
// Refresh avatar URL on tab change from inbox_settings and widgetBuilder tabs, to ensure real-time updates
// Refresh avatar URL on tab change from inbox-settings and widget-builder tabs, to ensure real-time updates
if (
this.inbox &&
['inbox_settings', 'widgetBuilder'].includes(this.tabs[index].key)
['inbox-settings', 'widget-builder'].includes(this.tabs[index].key)
)
this.avatarUrl = this.inbox.avatar_url;
},
onTabChange(selectedTabIndex) {
this.selectedTabIndex = selectedTabIndex;
this.refreshAvatarUrlOnTabChange(selectedTabIndex);
this.updateRouteWithoutRefresh(selectedTabIndex);
},
updateRouteWithoutRefresh(selectedTabIndex) {
const tab = this.tabs[selectedTabIndex];
if (!tab) return;
const { accountId, inboxId } = this.$route.params;
const baseUrl = `/app/accounts/${accountId}/settings/inboxes/${inboxId}`;
// Append the tab key only if it's not the default.
const newUrl =
tab.key === 'inbox-settings' ? baseUrl : `${baseUrl}/${tab.key}`;
// Update URL without triggering route watcher
window.history.replaceState(null, '', newUrl);
},
setTabFromRouteParam() {
const { tab: tabParam } = this.$route.params;
if (!tabParam) return;
const tabIndex = this.tabs.findIndex(tab => tab.key === tabParam);
this.selectedTabIndex = tabIndex === -1 ? 0 : tabIndex;
},
fetchInboxSettings() {
this.selectedTabIndex = 0;
this.selectedAgents = [];
this.$store.dispatch('agents/get');
this.$store.dispatch('teams/get');
@@ -393,6 +413,9 @@ export default {
this.selectedPortalSlug = this.inbox.help_center
? this.inbox.help_center.slug
: '';
// Set initial tab after inbox data is loaded
this.setTabFromRouteParam();
});
},
async updateInbox() {
@@ -513,7 +536,7 @@ export default {
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
class="mx-8 mt-5"
/>
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
<div v-if="selectedTabKey === 'inbox-settings'" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_SUB_TEXT')"
@@ -905,19 +928,19 @@ export default {
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'preChatForm'">
<div v-if="selectedTabKey === 'pre-chat-form'">
<PreChatFormSettings :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'businesshours'">
<div v-if="selectedTabKey === 'business-hours'">
<WeeklyAvailability :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'widgetBuilder'">
<div v-if="selectedTabKey === 'widget-builder'">
<WidgetBuilder :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'botConfiguration'">
<div v-if="selectedTabKey === 'bot-configuration'">
<BotConfiguration :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'whatsappHealth'">
<div v-if="selectedTabKey === 'whatsapp-health'">
<AccountHealth :health-data="healthData" />
</div>
</section>
@@ -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;
};
@@ -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', () => {
@@ -94,7 +94,7 @@ export default {
],
},
{
path: ':inboxId',
path: ':inboxId/:tab?',
name: 'settings_inbox_show',
component: Settings,
meta: {
@@ -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 };
}