feat: allow selecting month range in overview reports (#12701)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
@@ -8,11 +8,15 @@ import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
menuItems: {
|
menuItems: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
default: () => [],
|
||||||
validator: value => {
|
validator: value => {
|
||||||
return value.every(item => item.action && item.value && item.label);
|
return value.every(item => item.action && item.value && item.label);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
menuSections: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
thumbnailSize: {
|
thumbnailSize: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 20,
|
default: 20,
|
||||||
@@ -42,19 +46,62 @@ const { t } = useI18n();
|
|||||||
const searchInput = ref(null);
|
const searchInput = ref(null);
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
|
|
||||||
const filteredMenuItems = computed(() => {
|
const hasSections = computed(() => props.menuSections.length > 0);
|
||||||
if (!searchQuery.value) return props.menuItems;
|
|
||||||
|
|
||||||
return props.menuItems.filter(item =>
|
const flattenedMenuItems = computed(() => {
|
||||||
|
if (!hasSections.value) {
|
||||||
|
return props.menuItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.menuSections.flatMap(section => section.items || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredMenuItems = computed(() => {
|
||||||
|
if (!searchQuery.value) return flattenedMenuItems.value;
|
||||||
|
|
||||||
|
return flattenedMenuItems.value.filter(item =>
|
||||||
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filteredMenuSections = computed(() => {
|
||||||
|
if (!hasSections.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searchQuery.value) {
|
||||||
|
return props.menuSections;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
|
||||||
|
return props.menuSections
|
||||||
|
.map(section => {
|
||||||
|
const filteredItems = (section.items || []).filter(item =>
|
||||||
|
item.label.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
items: filteredItems,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(section => section.items.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
const handleAction = item => {
|
const handleAction = item => {
|
||||||
const { action, value, ...rest } = item;
|
const { action, value, ...rest } = item;
|
||||||
emit('action', { action, value, ...rest });
|
emit('action', { action, value, ...rest });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldShowEmptyState = computed(() => {
|
||||||
|
if (hasSections.value) {
|
||||||
|
return filteredMenuSections.value.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredMenuItems.value.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (searchInput.value && props.showSearch) {
|
if (searchInput.value && props.showSearch) {
|
||||||
searchInput.value.focus();
|
searchInput.value.focus();
|
||||||
@@ -64,9 +111,16 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
|
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
|
||||||
|
:class="{
|
||||||
|
'pt-2': !showSearch,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div v-if="showSearch" class="relative">
|
<div
|
||||||
|
v-if="showSearch"
|
||||||
|
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||||
<input
|
<input
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
@@ -78,6 +132,62 @@ onMounted(() => {
|
|||||||
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-if="hasSections">
|
||||||
|
<div
|
||||||
|
v-for="(section, sectionIndex) in filteredMenuSections"
|
||||||
|
:key="section.title || sectionIndex"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
v-if="section.title"
|
||||||
|
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{{ section.title }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-for="(item, itemIndex) in section.items"
|
||||||
|
:key="item.value || itemIndex"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
:class="{
|
||||||
|
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||||
|
'text-n-ruby-11': item.action === 'delete',
|
||||||
|
'text-n-slate-12': item.action !== 'delete',
|
||||||
|
}"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
@click="handleAction(item)"
|
||||||
|
>
|
||||||
|
<slot name="thumbnail" :item="item">
|
||||||
|
<Avatar
|
||||||
|
v-if="item.thumbnail"
|
||||||
|
:name="item.thumbnail.name"
|
||||||
|
:src="item.thumbnail.src"
|
||||||
|
:size="thumbnailSize"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
<Icon
|
||||||
|
v-if="item.icon"
|
||||||
|
:icon="item.icon"
|
||||||
|
class="flex-shrink-0 size-3.5"
|
||||||
|
/>
|
||||||
|
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||||
|
<span
|
||||||
|
v-if="item.label"
|
||||||
|
class="min-w-0 text-sm truncate"
|
||||||
|
:class="labelClass"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="sectionIndex < filteredMenuSections.length - 1"
|
||||||
|
class="h-px bg-n-alpha-2 mx-2 my-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<button
|
<button
|
||||||
v-for="(item, index) in filteredMenuItems"
|
v-for="(item, index) in filteredMenuItems"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -100,7 +210,11 @@ onMounted(() => {
|
|||||||
rounded-full
|
rounded-full
|
||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
|
<Icon
|
||||||
|
v-if="item.icon"
|
||||||
|
:icon="item.icon"
|
||||||
|
class="flex-shrink-0 size-3.5"
|
||||||
|
/>
|
||||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="item.label"
|
v-if="item.label"
|
||||||
@@ -110,8 +224,9 @@ onMounted(() => {
|
|||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
<div
|
<div
|
||||||
v-if="filteredMenuItems.length === 0"
|
v-if="shouldShowEmptyState"
|
||||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
|
|||||||
@@ -53,6 +53,8 @@
|
|||||||
"LAST_7_DAYS": "Last 7 days",
|
"LAST_7_DAYS": "Last 7 days",
|
||||||
"LAST_14_DAYS": "Last 14 days",
|
"LAST_14_DAYS": "Last 14 days",
|
||||||
"LAST_30_DAYS": "Last 30 days",
|
"LAST_30_DAYS": "Last 30 days",
|
||||||
|
"THIS_MONTH": "This month",
|
||||||
|
"LAST_MONTH": "Last month",
|
||||||
"LAST_3_MONTHS": "Last 3 months",
|
"LAST_3_MONTHS": "Last 3 months",
|
||||||
"LAST_6_MONTHS": "Last 6 months",
|
"LAST_6_MONTHS": "Last 6 months",
|
||||||
"LAST_YEAR": "Last year",
|
"LAST_YEAR": "Last year",
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ const tooltip = useHeatmapTooltip();
|
|||||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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"
|
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr]"
|
||||||
>
|
>
|
||||||
<template v-if="isLoading">
|
<template v-if="isLoading">
|
||||||
<div class="grid gap-[5px] flex-shrink-0">
|
<div class="grid gap-[5px] flex-shrink-0">
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref, computed } from 'vue';
|
import { onMounted, ref, computed, watch } from 'vue';
|
||||||
import { useToggle } from '@vueuse/core';
|
import { useToggle } from '@vueuse/core';
|
||||||
import MetricCard from '../overview/MetricCard.vue';
|
import MetricCard from '../overview/MetricCard.vue';
|
||||||
import BaseHeatmap from './BaseHeatmap.vue';
|
import BaseHeatmap from './BaseHeatmap.vue';
|
||||||
|
import HeatmapDateRangeSelector from './HeatmapDateRangeSelector.vue';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||||
|
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
|
||||||
import endOfDay from 'date-fns/endOfDay';
|
import endOfDay from 'date-fns/endOfDay';
|
||||||
|
import format from 'date-fns/format';
|
||||||
import getUnixTime from 'date-fns/getUnixTime';
|
import getUnixTime from 'date-fns/getUnixTime';
|
||||||
import startOfDay from 'date-fns/startOfDay';
|
import startOfDay from 'date-fns/startOfDay';
|
||||||
|
import startOfMonth from 'date-fns/startOfMonth';
|
||||||
import subDays from 'date-fns/subDays';
|
import subDays from 'date-fns/subDays';
|
||||||
import format from 'date-fns/format';
|
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
@@ -57,27 +60,33 @@ const uiFlags = useMapGetter('getOverviewUIFlags');
|
|||||||
const heatmapData = useMapGetter(props.storeGetter);
|
const heatmapData = useMapGetter(props.storeGetter);
|
||||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||||
|
|
||||||
const menuItems = [
|
const selectedFrom = ref(null);
|
||||||
{
|
const selectedTo = ref(null);
|
||||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
const selectedDaysBefore = ref(null);
|
||||||
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 selectedInbox = ref(null);
|
||||||
|
const isMonthFilter = ref(false);
|
||||||
|
const currentMonthOffset = ref(0);
|
||||||
|
|
||||||
const selectedDayFilter = computed(() =>
|
const selectedRange = computed(() => {
|
||||||
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
if (!selectedFrom.value || !selectedTo.value) {
|
||||||
);
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
from: selectedFrom.value,
|
||||||
|
to: selectedTo.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const numberOfRows = computed(() => {
|
||||||
|
if (!selectedRange.value) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const dateDifference = differenceInCalendarDays(
|
||||||
|
selectedRange.value.to,
|
||||||
|
selectedRange.value.from
|
||||||
|
);
|
||||||
|
return dateDifference + 1;
|
||||||
|
});
|
||||||
|
|
||||||
const inboxMenuItems = computed(() => {
|
const inboxMenuItems = computed(() => {
|
||||||
return [
|
return [
|
||||||
@@ -105,13 +114,42 @@ const selectedInboxFilter = computed(() => {
|
|||||||
|
|
||||||
const isLoading = computed(() => uiFlags.value[props.uiFlagKey]);
|
const isLoading = computed(() => uiFlags.value[props.uiFlagKey]);
|
||||||
|
|
||||||
const downloadHeatmapData = () => {
|
// Keeps relative presets (last 7 days / this month) aligned with "now" during live refreshes.
|
||||||
|
const resolveActiveRange = () => {
|
||||||
|
if (isMonthFilter.value && currentMonthOffset.value === 0) {
|
||||||
|
const now = new Date();
|
||||||
|
const monthStart = startOfMonth(now);
|
||||||
|
return {
|
||||||
|
from: startOfDay(monthStart),
|
||||||
|
to: endOfDay(now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMonthFilter.value && selectedDaysBefore.value !== null) {
|
||||||
const to = endOfDay(new Date());
|
const to = endOfDay(new Date());
|
||||||
|
return {
|
||||||
|
from: startOfDay(subDays(to, Number(selectedDaysBefore.value))),
|
||||||
|
to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedRange.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadHeatmapData = () => {
|
||||||
|
const range = resolveActiveRange();
|
||||||
|
if (!range) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { to } = range;
|
||||||
|
const shouldUseBackendDownload =
|
||||||
|
!isMonthFilter.value && !selectedInbox.value && props.downloadAction;
|
||||||
|
|
||||||
// If no inbox is selected and download action exists, use backend endpoint
|
// If no inbox is selected and download action exists, use backend endpoint
|
||||||
if (!selectedInbox.value && props.downloadAction) {
|
if (shouldUseBackendDownload) {
|
||||||
store.dispatch(props.downloadAction, {
|
store.dispatch(props.downloadAction, {
|
||||||
daysBefore: selectedDays.value,
|
daysBefore: selectedDaysBefore.value,
|
||||||
to: getUnixTime(to),
|
to: getUnixTime(to),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -150,7 +188,6 @@ const downloadHeatmapData = () => {
|
|||||||
downloadCsvFile(fileName, csvContent);
|
downloadCsvFile(fileName, csvContent);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showDropdown, toggleDropdown] = useToggle();
|
|
||||||
const [showInboxDropdown, toggleInboxDropdown] = useToggle();
|
const [showInboxDropdown, toggleInboxDropdown] = useToggle();
|
||||||
|
|
||||||
const fetchHeatmapData = () => {
|
const fetchHeatmapData = () => {
|
||||||
@@ -158,8 +195,12 @@ const fetchHeatmapData = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let to = endOfDay(new Date());
|
const range = resolveActiveRange();
|
||||||
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
if (!range) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from, to } = range;
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
metric: props.metric,
|
metric: props.metric,
|
||||||
@@ -178,25 +219,43 @@ const fetchHeatmapData = () => {
|
|||||||
store.dispatch(props.storeAction, params);
|
store.dispatch(props.storeAction, params);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAction = ({ value }) => {
|
|
||||||
toggleDropdown(false);
|
|
||||||
selectedDays.value = value;
|
|
||||||
fetchHeatmapData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInboxAction = ({ value }) => {
|
const handleInboxAction = ({ value }) => {
|
||||||
toggleInboxDropdown(false);
|
toggleInboxDropdown(false);
|
||||||
selectedInbox.value = value
|
selectedInbox.value = value
|
||||||
? inboxes.value.find(inbox => inbox.id === value)
|
? inboxes.value.find(inbox => inbox.id === value)
|
||||||
: null;
|
: null;
|
||||||
fetchHeatmapData();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
||||||
|
|
||||||
|
const handleRangeTypeChange = type => {
|
||||||
|
isMonthFilter.value = type === 'month';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMonthOffsetChange = offset => {
|
||||||
|
currentMonthOffset.value = offset;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [selectedFrom.value, selectedTo.value],
|
||||||
|
([from, to]) => {
|
||||||
|
if (from && to) {
|
||||||
|
fetchHeatmapData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => selectedInbox.value,
|
||||||
|
() => {
|
||||||
|
if (selectedRange.value) {
|
||||||
|
fetchHeatmapData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.dispatch('inboxes/get');
|
store.dispatch('inboxes/get');
|
||||||
fetchHeatmapData();
|
|
||||||
startRefetching();
|
startRefetching();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -205,25 +264,13 @@ onMounted(() => {
|
|||||||
<div class="flex flex-row flex-wrap max-w-full">
|
<div class="flex flex-row flex-wrap max-w-full">
|
||||||
<MetricCard :header="title">
|
<MetricCard :header="title">
|
||||||
<template #control>
|
<template #control>
|
||||||
<div
|
<HeatmapDateRangeSelector
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
v-model:from="selectedFrom"
|
||||||
class="relative flex items-center group"
|
v-model:to="selectedTo"
|
||||||
>
|
v-model:days-num="selectedDaysBefore"
|
||||||
<Button
|
@range-type-change="handleRangeTypeChange"
|
||||||
sm
|
@month-offset-change="handleMonthOffsetChange"
|
||||||
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
|
<div
|
||||||
v-on-clickaway="() => toggleInboxDropdown(false)"
|
v-on-clickaway="() => toggleInboxDropdown(false)"
|
||||||
class="relative flex items-center group"
|
class="relative flex items-center group"
|
||||||
@@ -241,22 +288,23 @@ onMounted(() => {
|
|||||||
:menu-items="inboxMenuItems"
|
:menu-items="inboxMenuItems"
|
||||||
show-search
|
show-search
|
||||||
:search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')"
|
: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]"
|
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full !min-w-56 max-w-56 max-h-96 overflow-y-auto"
|
||||||
@action="handleInboxAction($event)"
|
@action="handleInboxAction($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
v-tooltip="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
|
||||||
sm
|
sm
|
||||||
slate
|
slate
|
||||||
faded
|
faded
|
||||||
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
|
icon="i-lucide-download"
|
||||||
class="rounded-md group-hover:bg-n-alpha-2"
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
@click="downloadHeatmapData"
|
@click="downloadHeatmapData"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<BaseHeatmap
|
<BaseHeatmap
|
||||||
:heatmap-data="heatmapData"
|
:heatmap-data="heatmapData"
|
||||||
:number-of-rows="selectedDays + 1"
|
:number-of-rows="numberOfRows"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
:color-scheme="colorScheme"
|
:color-scheme="colorScheme"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch, defineModel } from 'vue';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import addMonths from 'date-fns/addMonths';
|
||||||
|
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
|
||||||
|
import endOfDay from 'date-fns/endOfDay';
|
||||||
|
import endOfMonth from 'date-fns/endOfMonth';
|
||||||
|
import startOfDay from 'date-fns/startOfDay';
|
||||||
|
import startOfMonth from 'date-fns/startOfMonth';
|
||||||
|
import subDays from 'date-fns/subDays';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['rangeTypeChange', 'monthOffsetChange']);
|
||||||
|
|
||||||
|
const fromModel = defineModel('from', { type: Date, default: null });
|
||||||
|
const toModel = defineModel('to', { type: Date, default: null });
|
||||||
|
const daysNumModel = defineModel('daysNum', { type: Number, default: null });
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
const DATE_FILTER_TYPES = {
|
||||||
|
DAY: 'day',
|
||||||
|
MONTH: 'month',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DATE_FILTER_ACTION = 'select_date_range';
|
||||||
|
|
||||||
|
const dayMenuItemConfigs = computed(() => [
|
||||||
|
{
|
||||||
|
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||||
|
value: 'last_7_days',
|
||||||
|
action: DATE_FILTER_ACTION,
|
||||||
|
type: DATE_FILTER_TYPES.DAY,
|
||||||
|
daysBefore: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_14_DAYS'),
|
||||||
|
value: 'last_14_days',
|
||||||
|
action: DATE_FILTER_ACTION,
|
||||||
|
type: DATE_FILTER_TYPES.DAY,
|
||||||
|
daysBefore: 13,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
|
||||||
|
value: 'last_30_days',
|
||||||
|
action: DATE_FILTER_ACTION,
|
||||||
|
type: DATE_FILTER_TYPES.DAY,
|
||||||
|
daysBefore: 29,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resolvedLocale = computed(
|
||||||
|
() =>
|
||||||
|
locale.value ||
|
||||||
|
(typeof navigator !== 'undefined' ? navigator.language : 'en')
|
||||||
|
);
|
||||||
|
|
||||||
|
const monthFormatter = computed(
|
||||||
|
() =>
|
||||||
|
new Intl.DateTimeFormat(resolvedLocale.value, {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const monthMenuItemConfigs = computed(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const offsets = [0, -1, -2];
|
||||||
|
|
||||||
|
return offsets.map(offset => ({
|
||||||
|
label:
|
||||||
|
offset === 0
|
||||||
|
? t('REPORT.DATE_RANGE_OPTIONS.THIS_MONTH')
|
||||||
|
: monthFormatter.value.format(addMonths(now, offset)),
|
||||||
|
value: offset === 0 ? 'this_month' : `month_${offset}`,
|
||||||
|
action: DATE_FILTER_ACTION,
|
||||||
|
type: DATE_FILTER_TYPES.MONTH,
|
||||||
|
monthOffset: offset,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedDateRangeValue = ref('');
|
||||||
|
|
||||||
|
const [showDropdown, toggleDropdown] = useToggle();
|
||||||
|
const monthOffset = ref(0);
|
||||||
|
|
||||||
|
const menuItems = computed(() => {
|
||||||
|
const selectedValue = selectedDateRangeValue.value;
|
||||||
|
return [...dayMenuItemConfigs.value, ...monthMenuItemConfigs.value].map(
|
||||||
|
config => ({
|
||||||
|
...config,
|
||||||
|
isSelected: selectedValue === config.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedDateRangeValue.value = menuItems.value[0]?.value || '';
|
||||||
|
|
||||||
|
const menuSections = computed(() => {
|
||||||
|
const dayItems = menuItems.value.filter(
|
||||||
|
item => item.type === DATE_FILTER_TYPES.DAY
|
||||||
|
);
|
||||||
|
const monthItems = menuItems.value.filter(
|
||||||
|
item => item.type === DATE_FILTER_TYPES.MONTH
|
||||||
|
);
|
||||||
|
|
||||||
|
return [{ items: dayItems }, { items: monthItems }].filter(
|
||||||
|
section => section.items.length > 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedConfig = computed(
|
||||||
|
() =>
|
||||||
|
menuItems.value.find(
|
||||||
|
menuItem => menuItem.value === selectedDateRangeValue.value
|
||||||
|
) || menuItems.value[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedLabel = computed(() => {
|
||||||
|
const selectedItem = menuItems.value.find(
|
||||||
|
item => item.value === selectedDateRangeValue.value
|
||||||
|
);
|
||||||
|
return selectedItem?.label || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const computeRange = config => {
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === DATE_FILTER_TYPES.MONTH) {
|
||||||
|
const now = new Date();
|
||||||
|
const baseMonthStart = startOfMonth(addMonths(now, monthOffset.value));
|
||||||
|
const from = startOfDay(baseMonthStart);
|
||||||
|
const isCurrentMonth =
|
||||||
|
config.value === 'this_month' && monthOffset.value === 0;
|
||||||
|
const to = isCurrentMonth
|
||||||
|
? endOfDay(now)
|
||||||
|
: endOfDay(endOfMonth(baseMonthStart));
|
||||||
|
const daysBefore = differenceInCalendarDays(to, from);
|
||||||
|
return { from, to, daysBefore };
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = endOfDay(new Date());
|
||||||
|
const from = startOfDay(subDays(to, Number(config.daysBefore)));
|
||||||
|
return { from, to, daysBefore: Number(config.daysBefore) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySelection = config => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
if (config.type === DATE_FILTER_TYPES.MONTH) {
|
||||||
|
monthOffset.value = config.monthOffset || 0;
|
||||||
|
} else {
|
||||||
|
monthOffset.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = computeRange(config);
|
||||||
|
if (!range) return;
|
||||||
|
|
||||||
|
const { from, to, daysBefore } = range;
|
||||||
|
fromModel.value = from;
|
||||||
|
toModel.value = to;
|
||||||
|
daysNumModel.value = daysBefore;
|
||||||
|
|
||||||
|
emit('rangeTypeChange', config.type);
|
||||||
|
emit('monthOffsetChange', monthOffset.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = ({ action, value }) => {
|
||||||
|
toggleDropdown(false);
|
||||||
|
if (action !== DATE_FILTER_ACTION) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedDateRangeValue.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => selectedConfig.value,
|
||||||
|
config => {
|
||||||
|
applySelection(config);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-on-click-outside="() => toggleDropdown(false)"
|
||||||
|
class="relative flex items-center group"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="selectedLabel"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="toggleDropdown()"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showDropdown"
|
||||||
|
:menu-items="menuItems"
|
||||||
|
:menu-sections="menuSections"
|
||||||
|
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||||
|
@action="handleAction($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user