feat: Refactor reports filters (#13443)

This commit is contained in:
Sivin Varghese 2026-02-06 18:22:30 +05:30 committed by GitHub
parent 04e747cc02
commit 0d3b59fd9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1678 additions and 1737 deletions

View File

@ -1,11 +1,14 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { import {
getActiveDateRange, getActiveDateRange,
moveCalendarDate, moveCalendarDate,
DATE_RANGE_TYPES, DATE_RANGE_TYPES,
CALENDAR_TYPES, CALENDAR_TYPES,
CALENDAR_PERIODS, CALENDAR_PERIODS,
isNavigableRange,
getRangeAtOffset,
} from './helpers/DatePickerHelper'; } from './helpers/DatePickerHelper';
import { import {
isValid, isValid,
@ -13,14 +16,14 @@ import {
subDays, subDays,
startOfDay, startOfDay,
endOfDay, endOfDay,
isBefore,
subMonths, subMonths,
addMonths, addMonths,
isSameMonth, isSameMonth,
differenceInCalendarMonths, differenceInCalendarMonths,
differenceInCalendarWeeks,
setMonth, setMonth,
setYear, setYear,
isAfter, getWeek,
} from 'date-fns'; } from 'date-fns';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import DatePickerButton from './components/DatePickerButton.vue'; import DatePickerButton from './components/DatePickerButton.vue';
@ -32,98 +35,187 @@ import CalendarWeek from './components/CalendarWeek.vue';
import CalendarFooter from './components/CalendarFooter.vue'; import CalendarFooter from './components/CalendarFooter.vue';
const emit = defineEmits(['dateRangeChanged']); const emit = defineEmits(['dateRangeChanged']);
const { LAST_7_DAYS, LAST_30_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES; const { t } = useI18n();
const dateRange = defineModel('dateRange', {
type: Array,
default: undefined,
});
const rangeType = defineModel('rangeType', {
type: String,
default: undefined,
});
const { LAST_7_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES; const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS; const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
const showDatePicker = ref(false); const showDatePicker = ref(false);
const calendarViews = ref({ start: WEEK, end: WEEK }); const calendarViews = ref({ start: WEEK, end: WEEK });
const currentDate = ref(new Date()); const currentDate = ref(new Date());
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
const selectedEndDate = ref(endOfDay(currentDate.value)); // Use dates from v-model if provided, otherwise default to last 7 days
// Setting the start and end calendar const selectedStartDate = ref(
const startCurrentDate = ref(startOfDay(selectedStartDate.value)); dateRange.value?.[0]
? startOfDay(dateRange.value[0])
: startOfDay(subDays(currentDate.value, 6)) // LAST_7_DAYS
);
const selectedEndDate = ref(
dateRange.value?.[1]
? endOfDay(dateRange.value[1])
: endOfDay(currentDate.value)
);
// Calendar month positioning (left and right calendars)
// These control which months are displayed in the dual calendar view
const startCurrentDate = ref(startOfMonth(selectedStartDate.value));
const endCurrentDate = ref( const endCurrentDate = ref(
isSameMonth(selectedStartDate.value, selectedEndDate.value) isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Moves to the start of the next month if dates are in the same month (Mounted case LAST_7_DAYS) ? startOfMonth(addMonths(selectedEndDate.value, 1)) // Same month: show next month on right (e.g., Jan 25-31 shows Jan + Feb)
: startOfMonth(selectedEndDate.value) // Always shows the month of the end date starting from the first (Mounted case LAST_7_DAYS) : startOfMonth(selectedEndDate.value) // Different months: show end month on right (e.g., Dec 5 - Jan 3 shows Dec + Jan)
); );
const selectingEndDate = ref(false); const selectingEndDate = ref(false);
const selectedRange = ref(LAST_7_DAYS); const selectedRange = ref(rangeType.value || LAST_7_DAYS);
const hoveredEndDate = ref(null); const hoveredEndDate = ref(null);
const monthOffset = ref(0);
const showMonthNavigation = computed(() =>
isNavigableRange(selectedRange.value)
);
const canNavigateNext = computed(() => {
if (!isNavigableRange(selectedRange.value)) return false;
// Compare selected start to the current period's start to determine if we're in the past
const currentRange = getActiveDateRange(
selectedRange.value,
currentDate.value
);
return selectedStartDate.value < currentRange.start;
});
const navigationLabel = computed(() => {
const range = selectedRange.value;
if (range === DATE_RANGE_TYPES.MONTH_TO_DATE) {
return new Intl.DateTimeFormat(navigator.language, {
month: 'long',
}).format(selectedStartDate.value);
}
if (range === DATE_RANGE_TYPES.THIS_WEEK) {
const currentWeekRange = getActiveDateRange(range, currentDate.value);
const isCurrentWeek =
selectedStartDate.value.getTime() === currentWeekRange.start.getTime();
if (isCurrentWeek) return null;
const weekNumber = getWeek(selectedStartDate.value, { weekStartsOn: 1 });
return t('DATE_PICKER.WEEK_NUMBER', { weekNumber });
}
return null;
});
const manualStartDate = ref(selectedStartDate.value); const manualStartDate = ref(selectedStartDate.value);
const manualEndDate = ref(selectedEndDate.value); const manualEndDate = ref(selectedEndDate.value);
// Watcher will set the start and end dates based on the selected range // Watcher 1: Sync v-model props from parent component
watch(selectedRange, newRange => { // Handles: URL params, parent component updates, rangeType changes
if (newRange !== CUSTOM_RANGE) {
// If selecting a range other than last 7 days or last 30 days, set the start and end dates to the selected start and end dates
// If selecting last 7 days or last 30 days is, set the start date to the selected start date
// and the end date to one month ahead of the start date if the start date and end date are in the same month
// Otherwise set the end date to the selected end date
const isLastSevenOrThirtyDays =
newRange === LAST_7_DAYS || newRange === LAST_30_DAYS;
startCurrentDate.value = selectedStartDate.value;
endCurrentDate.value =
isLastSevenOrThirtyDays &&
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedStartDate.value, 1))
: selectedEndDate.value;
selectingEndDate.value = false;
} else if (!selectingEndDate.value) {
// If selecting a custom range and not selecting an end date, set the start date to the selected start date
startCurrentDate.value = startOfDay(currentDate.value);
}
});
// Watcher will set the input values based on the selected start and end dates
watch( watch(
[selectedStartDate, selectedEndDate], [rangeType, dateRange],
([newStart, newEnd]) => { ([newRangeType, newDateRange]) => {
if (isValid(newStart)) { if (newRangeType && newRangeType !== selectedRange.value) {
manualStartDate.value = newStart; selectedRange.value = newRangeType;
} else { monthOffset.value = 0;
manualStartDate.value = selectedStartDate.value;
// If rangeType changes without dateRange, recompute dates from the range
if (!newDateRange && newRangeType !== CUSTOM_RANGE) {
const activeDates = getActiveDateRange(newRangeType, currentDate.value);
if (activeDates) {
selectedStartDate.value = startOfDay(activeDates.start);
selectedEndDate.value = endOfDay(activeDates.end);
}
}
} }
if (isValid(newEnd)) { // When parent provides new dateRange (e.g., from URL params)
manualEndDate.value = newEnd; // Skip if navigating with arrows offset controls dates in that case
} else { if (newDateRange?.[0] && newDateRange?.[1] && monthOffset.value === 0) {
manualEndDate.value = selectedEndDate.value; selectedStartDate.value = startOfDay(newDateRange[0]);
selectedEndDate.value = endOfDay(newDateRange[1]);
// Update calendar to show the months of the new date range
startCurrentDate.value = startOfMonth(newDateRange[0]);
endCurrentDate.value = isSameMonth(newDateRange[0], newDateRange[1])
? startOfMonth(addMonths(newDateRange[1], 1))
: startOfMonth(newDateRange[1]);
// Recalculate offset so arrow navigation is relative to restored range
// TODO: When offset resolves to 0 (current period), the end date may be
// stale if the URL was saved on a previous day. "This month" / "This week"
// should show up-to-today dates for the current period. For now, the stale
// end date is shown until the user clicks an arrow or re-selects the range.
if (isNavigableRange(selectedRange.value)) {
const current = getActiveDateRange(
selectedRange.value,
currentDate.value
);
if (selectedRange.value === DATE_RANGE_TYPES.THIS_WEEK) {
monthOffset.value = differenceInCalendarWeeks(
newDateRange[0],
current.start,
{ weekStartsOn: 1 }
);
} else {
monthOffset.value = differenceInCalendarMonths(
newDateRange[0],
current.start
);
}
}
} }
}, },
{ immediate: true } { immediate: true }
); );
// Watcher to ensure dates are always in logical order // Watcher 2: Keep manual input fields in sync with selected dates
// This watch is will ensure that the start date is always before the end date // Updates the input field values when dates change programmatically
watch( watch(
[startCurrentDate, endCurrentDate], [selectedStartDate, selectedEndDate],
([newStart, newEnd], [oldStart, oldEnd]) => { ([newStart, newEnd]) => {
const monthDifference = differenceInCalendarMonths(newEnd, newStart); manualStartDate.value = isValid(newStart)
? newStart
if (newStart !== oldStart) { : selectedStartDate.value;
if (isAfter(newStart, newEnd) || monthDifference === 0) { manualEndDate.value = isValid(newEnd) ? newEnd : selectedEndDate.value;
// Adjust the end date forward if the start date is adjusted and is after the end date or in the same month
endCurrentDate.value = addMonths(newStart, 1);
}
}
if (newEnd !== oldEnd) {
if (isBefore(newEnd, newStart) || monthDifference === 0) {
// Adjust the start date backward if the end date is adjusted and is before the start date or in the same month
startCurrentDate.value = subMonths(newEnd, 1);
}
}
}, },
{ immediate: true, deep: true } { immediate: true }
); );
const setDateRange = range => { const setDateRange = range => {
selectedRange.value = range.value; selectedRange.value = range.value;
monthOffset.value = 0;
const { start, end } = getActiveDateRange(range.value, currentDate.value); const { start, end } = getActiveDateRange(range.value, currentDate.value);
selectedStartDate.value = start; selectedStartDate.value = start;
selectedEndDate.value = end; selectedEndDate.value = end;
// Position calendar to show the months of the selected range
startCurrentDate.value = startOfMonth(start);
endCurrentDate.value = isSameMonth(start, end)
? startOfMonth(addMonths(start, 1))
: startOfMonth(end);
};
const navigateMonth = direction => {
monthOffset.value += direction === 'prev' ? -1 : 1;
if (monthOffset.value > 0) monthOffset.value = 0;
const { start, end } = getRangeAtOffset(
selectedRange.value,
monthOffset.value,
currentDate.value
);
selectedStartDate.value = start;
selectedEndDate.value = end;
startCurrentDate.value = startOfMonth(start);
endCurrentDate.value = isSameMonth(start, end)
? startOfMonth(addMonths(start, 1))
: startOfMonth(end);
emit('dateRangeChanged', [start, end, selectedRange.value]);
}; };
const moveCalendar = (calendar, direction, period = MONTH) => { const moveCalendar = (calendar, direction, period = MONTH) => {
@ -134,12 +226,27 @@ const moveCalendar = (calendar, direction, period = MONTH) => {
direction, direction,
period period
); );
startCurrentDate.value = start;
endCurrentDate.value = end; // Prevent calendar months from overlapping
const monthDiff = differenceInCalendarMonths(end, start);
if (monthDiff === 0) {
// If they would be the same month, adjust the other calendar
if (calendar === START_CALENDAR) {
endCurrentDate.value = addMonths(start, 1);
startCurrentDate.value = start;
} else {
startCurrentDate.value = subMonths(end, 1);
endCurrentDate.value = end;
}
} else {
startCurrentDate.value = start;
endCurrentDate.value = end;
}
}; };
const selectDate = day => { const selectDate = day => {
selectedRange.value = CUSTOM_RANGE; selectedRange.value = CUSTOM_RANGE;
monthOffset.value = 0;
if (!selectingEndDate.value || day < selectedStartDate.value) { if (!selectingEndDate.value || day < selectedStartDate.value) {
selectedStartDate.value = day; selectedStartDate.value = day;
selectedEndDate.value = null; selectedEndDate.value = null;
@ -175,10 +282,10 @@ const openCalendar = (index, calendarType, period = MONTH) => {
const updateManualInput = (newDate, calendarType) => { const updateManualInput = (newDate, calendarType) => {
if (calendarType === START_CALENDAR) { if (calendarType === START_CALENDAR) {
selectedStartDate.value = newDate; selectedStartDate.value = newDate;
startCurrentDate.value = newDate; startCurrentDate.value = startOfMonth(newDate);
} else { } else {
selectedEndDate.value = newDate; selectedEndDate.value = newDate;
endCurrentDate.value = newDate; endCurrentDate.value = startOfMonth(newDate);
} }
selectingEndDate.value = false; selectingEndDate.value = false;
}; };
@ -188,13 +295,22 @@ const handleManualInputError = message => {
}; };
const resetDatePicker = () => { const resetDatePicker = () => {
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day // Calculate Last 7 days from today
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead const startDate = startOfDay(subDays(currentDate.value, 6));
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6)); const endDate = endOfDay(currentDate.value);
selectedEndDate.value = endOfDay(currentDate.value);
selectedStartDate.value = startDate;
selectedEndDate.value = endDate;
// Position calendar to show the months of Last 7 days
// Example: If today is Feb 5, Last 7 days = Jan 30 - Feb 5, so show Jan + Feb
startCurrentDate.value = startOfMonth(startDate);
endCurrentDate.value = isSameMonth(startDate, endDate)
? startOfMonth(addMonths(startDate, 1))
: startOfMonth(endDate);
selectingEndDate.value = false; selectingEndDate.value = false;
selectedRange.value = LAST_7_DAYS; selectedRange.value = LAST_7_DAYS;
// Reset view modes if they are being used to toggle between different calendar views monthOffset.value = 0;
calendarViews.value = { start: WEEK, end: WEEK }; calendarViews.value = { start: WEEK, end: WEEK };
}; };
@ -203,26 +319,58 @@ const emitDateRange = () => {
useAlert('Please select a valid time range'); useAlert('Please select a valid time range');
} else { } else {
showDatePicker.value = false; showDatePicker.value = false;
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]); emit('dateRangeChanged', [
selectedStartDate.value,
selectedEndDate.value,
selectedRange.value,
]);
} }
}; };
// Called when picker opens - positions calendar to show selected date range
// Fixes issue where calendar showed wrong months when loaded from URL params
const initializeCalendarMonths = () => {
if (selectedStartDate.value && selectedEndDate.value) {
startCurrentDate.value = startOfMonth(selectedStartDate.value);
endCurrentDate.value = isSameMonth(
selectedStartDate.value,
selectedEndDate.value
)
? startOfMonth(addMonths(selectedEndDate.value, 1))
: startOfMonth(selectedEndDate.value);
}
};
const toggleDatePicker = () => {
showDatePicker.value = !showDatePicker.value;
if (showDatePicker.value) initializeCalendarMonths();
};
const closeDatePicker = () => { const closeDatePicker = () => {
showDatePicker.value = false; if (isValid(selectedStartDate.value) && isValid(selectedEndDate.value)) {
emitDateRange();
} else {
showDatePicker.value = false;
}
}; };
</script> </script>
<template> <template>
<div v-on-clickaway="closeDatePicker" class="relative font-inter"> <div class="relative flex-shrink-0 font-inter">
<DatePickerButton <DatePickerButton
:selected-start-date="selectedStartDate" :selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate" :selected-end-date="selectedEndDate"
:selected-range="selectedRange" :selected-range="selectedRange"
@open="showDatePicker = !showDatePicker" :show-month-navigation="showMonthNavigation"
:can-navigate-next="canNavigateNext"
:navigation-label="navigationLabel"
@open="toggleDatePicker"
@navigate-month="navigateMonth"
/> />
<div <div
v-if="showDatePicker" v-if="showDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container" v-on-clickaway="closeDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
> >
<CalendarDateRange <CalendarDateRange
:selected-range="selectedRange" :selected-range="selectedRange"

View File

@ -46,13 +46,13 @@ const onClickSetView = (type, mode) => {
xs xs
icon="i-lucide-chevron-left" icon="i-lucide-chevron-left"
class="rtl:rotate-180" class="rtl:rotate-180"
@click="onClickPrev(calendarType)" @click.stop="onClickPrev(calendarType)"
/> />
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button <button
v-if="firstButtonLabel" v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand" class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
@click="onClickSetView(calendarType, viewMode)" @click.stop="onClickSetView(calendarType, viewMode)"
> >
{{ firstButtonLabel }} {{ firstButtonLabel }}
</button> </button>
@ -60,7 +60,7 @@ const onClickSetView = (type, mode) => {
v-if="buttonLabel" v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12" class="p-0 text-sm font-medium text-center text-n-slate-12"
:class="{ 'hover:text-n-brand': viewMode }" :class="{ 'hover:text-n-brand': viewMode }"
@click="onClickSetView(calendarType, YEAR)" @click.stop="onClickSetView(calendarType, YEAR)"
> >
{{ buttonLabel }} {{ buttonLabel }}
</button> </button>
@ -71,7 +71,7 @@ const onClickSetView = (type, mode) => {
xs xs
icon="i-lucide-chevron-right" icon="i-lucide-chevron-right"
class="rtl:rotate-180" class="rtl:rotate-180"
@click="onClickNext(calendarType)" @click.stop="onClickNext(calendarType)"
/> />
</div> </div>
</template> </template>

View File

@ -18,24 +18,25 @@ const setDateRange = range => {
<template> <template>
<div class="w-[200px] flex flex-col items-start"> <div class="w-[200px] flex flex-col items-start">
<h4 <h4
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12" class="w-full px-5 py-4 text-xs font-bold capitalize text-start text-n-slate-10"
> >
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }} {{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4> </h4>
<div class="flex flex-col items-start w-full"> <div class="flex flex-col items-start w-full">
<button <template v-for="range in dateRanges" :key="range.label">
v-for="range in dateRanges" <div v-if="range.separator" class="w-full border-t border-n-strong" />
:key="range.label" <button
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3" class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
:class=" :class="
range.value === selectedRange range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active' ? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12' : 'text-n-slate-12'
" "
@click="setDateRange(range)" @click="setDateRange(range)"
> >
{{ $t(range.label) }} {{ $t(range.label) }}
</button> </button>
</template>
</div> </div>
</div> </div>
</template> </template>

View File

@ -23,7 +23,6 @@ const onClickApply = () => {
/> />
<NextButton <NextButton
sm sm
ghost
:label="$t('DATE_PICKER.APPLY_BUTTON')" :label="$t('DATE_PICKER.APPLY_BUTTON')"
@click="onClickApply" @click="onClickApply"
/> />

View File

@ -78,7 +78,7 @@ const selectMonth = index => {
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': 'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
index !== activeMonthIndex, index !== activeMonthIndex,
}" }"
@click="selectMonth(index)" @click.stop="selectMonth(index)"
> >
{{ month }} {{ month }}
</button> </button>

View File

@ -77,7 +77,7 @@ const selectYear = year => {
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear, 'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear, 'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
}" }"
@click="selectYear(year)" @click.stop="selectYear(year)"
> >
{{ year }} {{ year }}
</button> </button>

View File

@ -2,6 +2,8 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { dateRanges } from '../helpers/DatePickerHelper'; import { dateRanges } from '../helpers/DatePickerHelper';
import { format, isSameYear, isValid } from 'date-fns'; import { format, isSameYear, isValid } from 'date-fns';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
selectedStartDate: Date, selectedStartDate: Date,
@ -10,9 +12,21 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
showMonthNavigation: {
type: Boolean,
default: false,
},
canNavigateNext: {
type: Boolean,
default: false,
},
navigationLabel: {
type: String,
default: null,
},
}); });
const emit = defineEmits(['open']); const emit = defineEmits(['open', 'navigateMonth']);
const formatDateRange = computed(() => { const formatDateRange = computed(() => {
const startDate = props.selectedStartDate; const startDate = props.selectedStartDate;
@ -22,19 +36,15 @@ const formatDateRange = computed(() => {
return 'Select a date range'; return 'Select a date range';
} }
const formatString = isSameYear(startDate, endDate) const crossesYears = !isSameYear(startDate, endDate);
? 'MMM d' // Same year: "Apr 1"
: 'MMM d yyyy'; // Different years: "Apr 1 2025"
if (isSameYear(startDate, new Date()) && isSameYear(endDate, new Date())) { // Always show years when crossing year boundaries
// Both dates are in the current year if (crossesYears) {
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`; return `${format(startDate, 'MMM d, yyyy')} - ${format(endDate, 'MMM d, yyyy')}`;
} }
// At least one date is not in the current year
return `${format(startDate, formatString)} - ${format( // For same year, always show the year for clarity
endDate, return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`;
formatString
)}`;
}); });
const activeDateRange = computed( const activeDateRange = computed(
@ -47,17 +57,46 @@ const openDatePicker = () => {
</script> </script>
<template> <template>
<button <div class="inline-flex items-center gap-1">
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1" <button
@click="openDatePicker" class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1 flex-shrink-0"
> @click="openDatePicker"
<fluent-icon class="text-n-slate-12" icon="calendar" size="16" /> >
<span class="text-sm font-medium text-n-slate-12"> <Icon
{{ $t(activeDateRange) }} icon="i-lucide-calendar-range"
</span> class="text-n-slate-11 size-3.5 flex-shrink-0"
<span class="text-sm font-medium text-n-slate-11"> />
{{ formatDateRange }} <span class="text-sm font-medium text-n-slate-12 truncate">
</span> {{ navigationLabel || $t(activeDateRange) }}
<fluent-icon class="text-n-slate-12" icon="chevron-down" size="14" /> </span>
</button> <span class="text-sm font-medium text-n-slate-11 truncate">
{{ formatDateRange }}
</span>
<Icon
icon="i-lucide-chevron-down"
class="text-n-slate-12 size-4 flex-shrink-0"
/>
</button>
<NextButton
v-if="showMonthNavigation"
v-tooltip.top="$t('DATE_PICKER.PREVIOUS_PERIOD')"
slate
faded
sm
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
@click="emit('navigateMonth', 'prev')"
/>
<NextButton
v-if="showMonthNavigation"
v-tooltip.top="$t('DATE_PICKER.NEXT_PERIOD')"
slate
faded
sm
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
:disabled="!canNavigateNext"
@click="emit('navigateMonth', 'next')"
/>
</div>
</template> </template>

View File

@ -10,6 +10,8 @@ import {
isSameMonth, isSameMonth,
format, format,
startOfWeek, startOfWeek,
endOfWeek,
addWeeks,
addDays, addDays,
eachDayOfInterval, eachDayOfInterval,
endOfMonth, endOfMonth,
@ -34,13 +36,27 @@ export const dateRanges = [
{ {
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS', label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
value: 'last3months', value: 'last3months',
separator: true,
}, },
{ {
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS', label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
value: 'last6months', value: 'last6months',
}, },
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' }, { label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' },
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE', value: 'custom' }, {
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.THIS_WEEK',
value: 'thisWeek',
separator: true,
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.MONTH_TO_DATE',
value: 'monthToDate',
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE',
value: 'custom',
separator: true,
},
]; ];
export const DATE_RANGE_TYPES = { export const DATE_RANGE_TYPES = {
@ -49,6 +65,8 @@ export const DATE_RANGE_TYPES = {
LAST_3_MONTHS: 'last3months', LAST_3_MONTHS: 'last3months',
LAST_6_MONTHS: 'last6months', LAST_6_MONTHS: 'last6months',
LAST_YEAR: 'lastYear', LAST_YEAR: 'lastYear',
THIS_WEEK: 'thisWeek',
MONTH_TO_DATE: 'monthToDate',
CUSTOM_RANGE: 'custom', CUSTOM_RANGE: 'custom',
}; };
@ -210,6 +228,14 @@ export const getActiveDateRange = (range, currentDate) => {
start: startOfDay(subMonths(currentDate, 12)), start: startOfDay(subMonths(currentDate, 12)),
end: endOfDay(currentDate), end: endOfDay(currentDate),
}), }),
thisWeek: () => ({
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: 1 })),
end: endOfDay(currentDate),
}),
monthToDate: () => ({
start: startOfDay(startOfMonth(currentDate)),
end: endOfDay(currentDate),
}),
custom: () => ({ start: currentDate, end: currentDate }), custom: () => ({ start: currentDate, end: currentDate }),
}; };
@ -217,3 +243,48 @@ export const getActiveDateRange = (range, currentDate) => {
ranges[range] || (() => ({ start: currentDate, end: currentDate })) ranges[range] || (() => ({ start: currentDate, end: currentDate }))
)(); )();
}; };
export const isNavigableRange = rangeType =>
rangeType === DATE_RANGE_TYPES.MONTH_TO_DATE ||
rangeType === DATE_RANGE_TYPES.THIS_WEEK;
const WEEK_START = 1; // Monday
const getWeekRangeAtOffset = (offset, currentDate) => {
if (offset === 0) {
return {
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: WEEK_START })),
end: endOfDay(currentDate),
};
}
const targetWeek = addWeeks(currentDate, offset);
return {
start: startOfDay(startOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
end: endOfDay(endOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
};
};
const getMonthRangeAtOffset = (offset, currentDate) => {
if (offset === 0) {
return {
start: startOfDay(startOfMonth(currentDate)),
end: endOfDay(currentDate),
};
}
const targetMonth = addMonths(currentDate, offset);
return {
start: startOfDay(startOfMonth(targetMonth)),
end: endOfDay(endOfMonth(targetMonth)),
};
};
export const getRangeAtOffset = (
rangeType,
offset,
currentDate = new Date()
) => {
if (rangeType === DATE_RANGE_TYPES.THIS_WEEK) {
return getWeekRangeAtOffset(offset, currentDate);
}
return getMonthRangeAtOffset(offset, currentDate);
};

View File

@ -34,8 +34,8 @@ const value = defineModel({
<input <input
v-model="value" v-model="value"
:placeholder="inputPlaceholder" :placeholder="inputPlaceholder"
type="text" type="search"
class="w-full mb-0 text-sm !outline-0 bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base" class="w-full mb-0 text-sm !outline-0 !outline-none bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
/> />
</div> </div>
<!-- Clear filter button --> <!-- Clear filter button -->

View File

@ -1,5 +1,8 @@
{ {
"DATE_PICKER": { "DATE_PICKER": {
"PREVIOUS_PERIOD": "Previous period",
"NEXT_PERIOD": "Next period",
"WEEK_NUMBER": "Week #{weekNumber}",
"APPLY_BUTTON": "Apply", "APPLY_BUTTON": "Apply",
"CLEAR_BUTTON": "Clear", "CLEAR_BUTTON": "Clear",
"DATE_RANGE_INPUT": { "DATE_RANGE_INPUT": {
@ -13,6 +16,8 @@
"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",
"THIS_WEEK": "This week",
"MONTH_TO_DATE": "This month",
"CUSTOM_RANGE": "Custom date range" "CUSTOM_RANGE": "Custom date range"
} }
} }

View File

@ -128,11 +128,16 @@
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
"HEADER": "Agents Overview", "HEADER": "Agents Overview",
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agents name to learn more.", "DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent's name to learn more.",
"LOADING_CHART": "Loading chart data...", "LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports", "DOWNLOAD_AGENT_REPORTS": "Download agent reports",
"FILTER_DROPDOWN_LABEL": "Select Agent", "FILTER_DROPDOWN_LABEL": "Select Agent",
"FILTERS": {
"INPUT_PLACEHOLDER": {
"AGENTS": "Search agents"
}
},
"METRICS": { "METRICS": {
"CONVERSATIONS": { "CONVERSATIONS": {
"NAME": "Conversations", "NAME": "Conversations",
@ -201,6 +206,11 @@
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_LABEL_REPORTS": "Download label reports", "DOWNLOAD_LABEL_REPORTS": "Download label reports",
"FILTER_DROPDOWN_LABEL": "Select Label", "FILTER_DROPDOWN_LABEL": "Select Label",
"FILTERS": {
"INPUT_PLACEHOLDER": {
"LABELS": "Search labels"
}
},
"METRICS": { "METRICS": {
"CONVERSATIONS": { "CONVERSATIONS": {
"NAME": "Conversations", "NAME": "Conversations",
@ -271,6 +281,11 @@
"FILTER_DROPDOWN_LABEL": "Select Inbox", "FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes", "ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox", "SEARCH_INBOX": "Search Inbox",
"FILTERS": {
"INPUT_PLACEHOLDER": {
"INBOXES": "Search inboxes"
}
},
"METRICS": { "METRICS": {
"CONVERSATIONS": { "CONVERSATIONS": {
"NAME": "Conversations", "NAME": "Conversations",
@ -334,11 +349,19 @@
}, },
"TEAM_REPORTS": { "TEAM_REPORTS": {
"HEADER": "Team Overview", "HEADER": "Team Overview",
"DESCRIPTION": "Get a snapshot of your teams performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.", "DESCRIPTION": "Get a snapshot of your team's performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
"LOADING_CHART": "Loading chart data...", "LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_TEAM_REPORTS": "Download team reports", "DOWNLOAD_TEAM_REPORTS": "Download team reports",
"FILTER_DROPDOWN_LABEL": "Select Team", "FILTER_DROPDOWN_LABEL": "Select Team",
"FILTERS": {
"ADD_FILTER": "Add filter",
"CLEAR_ALL": "Clear all",
"NO_FILTER": "No filters available",
"INPUT_PLACEHOLDER": {
"TEAMS": "Search teams"
}
},
"METRICS": { "METRICS": {
"CONVERSATIONS": { "CONVERSATIONS": {
"NAME": "Conversations", "NAME": "Conversations",

View File

@ -1,7 +1,7 @@
<script> <script>
import { useAlert, useTrack } from 'dashboard/composables'; import { useAlert, useTrack } from 'dashboard/composables';
import BotMetrics from './components/BotMetrics.vue'; import BotMetrics from './components/BotMetrics.vue';
import ReportFilterSelector from './components/FilterSelector.vue'; import ReportFilters from './components/ReportFilters.vue';
import { GROUP_BY_FILTER } from './constants'; import { GROUP_BY_FILTER } from './constants';
import ReportContainer from './ReportContainer.vue'; import ReportContainer from './ReportContainer.vue';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events'; import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
@ -12,7 +12,7 @@ export default {
components: { components: {
BotMetrics, BotMetrics,
ReportHeader, ReportHeader,
ReportFilterSelector, ReportFilters,
ReportContainer, ReportContainer,
}, },
data() { data() {
@ -88,10 +88,10 @@ export default {
<template> <template>
<ReportHeader :header-title="$t('BOT_REPORTS.HEADER')" /> <ReportHeader :header-title="$t('BOT_REPORTS.HEADER')" />
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<ReportFilterSelector <ReportFilters
:show-agents-filter="false" :show-entity-filter="false"
show-group-by-filter show-group-by
:show-business-hours-switch="false" :show-business-hours="false"
@filter-change="onFilterChange" @filter-change="onFilterChange"
/> />

View File

@ -52,6 +52,9 @@ export default {
); );
}, },
}, },
mounted() {
this.$store.dispatch('agents/get');
},
methods: { methods: {
getAllData() { getAllData() {
try { try {

View File

@ -1,7 +1,7 @@
<script> <script>
import V4Button from 'dashboard/components-next/button/Button.vue'; import V4Button from 'dashboard/components-next/button/Button.vue';
import { useAlert, useTrack } from 'dashboard/composables'; import { useAlert, useTrack } from 'dashboard/composables';
import ReportFilterSelector from './components/FilterSelector.vue'; import ReportFilters from './components/ReportFilters.vue';
import { GROUP_BY_FILTER } from './constants'; import { GROUP_BY_FILTER } from './constants';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events'; import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { generateFileName } from 'dashboard/helper/downloadHelper'; import { generateFileName } from 'dashboard/helper/downloadHelper';
@ -22,7 +22,7 @@ export default {
name: 'ConversationReports', name: 'ConversationReports',
components: { components: {
ReportHeader, ReportHeader,
ReportFilterSelector, ReportFilters,
ReportContainer, ReportContainer,
V4Button, V4Button,
}, },
@ -115,10 +115,10 @@ export default {
@click="downloadConversationReports" @click="downloadConversationReports"
/> />
</ReportHeader> </ReportHeader>
<div class="flex flex-col gap-3"> <div class="flex flex-col">
<ReportFilterSelector <ReportFilters
:show-agents-filter="false" :show-entity-filter="false"
show-group-by-filter show-group-by
@filter-change="onFilterChange" @filter-change="onFilterChange"
/> />
<ReportContainer :group-by="groupBy" /> <ReportContainer :group-by="groupBy" />

View File

@ -145,7 +145,7 @@ export default {
<template> <template>
<div <div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 px-6 py-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 px-6 py-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 mt-4"
> >
<div <div
v-for="metric in metrics" v-for="metric in metrics"

View File

@ -2,6 +2,8 @@
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { subDays, fromUnixTime } from 'date-fns';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper'; import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import { import {
buildFilterList, buildFilterList,
@ -13,6 +15,12 @@ import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue'; import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
import AddFilterChip from '../Filters/v3/AddFilterChip.vue'; import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue'; import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
import {
parseReportURLParams,
parseFilterURLParams,
generateCompleteURLParams,
} from '../../helpers/reportFilterHelper';
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
const props = defineProps({ const props = defineProps({
showTeamFilter: { showTeamFilter: {
@ -25,16 +33,29 @@ const emit = defineEmits(['filterChange']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
const route = useRoute();
const router = useRouter();
// Initialize from URL params immediately
const urlParams = parseReportURLParams(route.query);
const urlFilters = parseFilterURLParams(route.query);
const initialDateRange =
urlParams.from && urlParams.to
? [fromUnixTime(urlParams.from), fromUnixTime(urlParams.to)]
: [subDays(new Date(), 6), new Date()];
const showDropdownMenu = ref(false); const showDropdownMenu = ref(false);
const showSubDropdownMenu = ref(false); const showSubDropdownMenu = ref(false);
const activeFilterType = ref(''); const activeFilterType = ref('');
const customDateRange = ref([new Date(), new Date()]); const customDateRange = ref(initialDateRange);
const selectedDateRange = ref(urlParams.range || DATE_RANGE_TYPES.LAST_7_DAYS);
const appliedFilters = ref({ const appliedFilters = ref({
user_ids: null, user_ids: urlFilters.agent_id,
inbox_id: null, inbox_id: urlFilters.inbox_id,
team_id: null, team_id: urlFilters.team_id,
rating: null, rating: urlFilters.rating,
}); });
const agents = computed(() => store.getters['agents/getAgents']); const agents = computed(() => store.getters['agents/getAgents']);
@ -111,13 +132,17 @@ const activeFilters = computed(() => {
const activeKeys = Object.keys(appliedFilters.value).filter( const activeKeys = Object.keys(appliedFilters.value).filter(
key => appliedFilters.value[key] key => appliedFilters.value[key]
); );
return activeKeys.map(key => { return activeKeys.map(key => {
const filterType = getFilterType(key, 'keyToType'); const filterType = getFilterType(key, 'keyToType');
const items = getFilterSource(filterType); const items = getFilterSource(filterType);
const item = getActiveFilter(items, filterType, appliedFilters.value[key]); const item = getActiveFilter(items, filterType, appliedFilters.value[key]);
const displayName =
item?.name || item?.title || `ID: ${appliedFilters.value[key]}`;
return { return {
id: item?.id, id: item?.id || appliedFilters.value[key],
name: item?.name || '', name: displayName,
type: filterType, type: filterType,
options: getFilterOptions(filterType), options: getFilterOptions(filterType),
}; };
@ -130,7 +155,23 @@ const hasActiveFilters = computed(() =>
const isAllFilterSelected = computed(() => !filterListMenuItems.value.length); const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
const updateURLParams = () => {
const params = generateCompleteURLParams({
from: from.value,
to: to.value,
range: selectedDateRange.value,
filters: {
agent_id: appliedFilters.value.user_ids,
inbox_id: appliedFilters.value.inbox_id,
team_id: appliedFilters.value.team_id,
rating: appliedFilters.value.rating,
},
});
router.replace({ query: params });
};
const emitChange = () => { const emitChange = () => {
updateURLParams();
emit('filterChange', { emit('filterChange', {
from: from.value, from: from.value,
to: to.value, to: to.value,
@ -200,7 +241,9 @@ const openActiveFilterDropdown = filterType => {
}; };
const onDateRangeChange = value => { const onDateRangeChange = value => {
customDateRange.value = value; const [startDate, endDate, rangeType] = value;
customDateRange.value = [startDate, endDate];
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
emitChange(); emitChange();
}; };
@ -211,7 +254,11 @@ onMounted(() => {
<template> <template>
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row"> <div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
<WootDatePicker @date-range-changed="onDateRangeChange" /> <WootDatePicker
v-model:date-range="customDateRange"
v-model:range-type="selectedDateRange"
@date-range-changed="onDateRangeChange"
/>
<div <div
class="flex flex-col flex-wrap items-start gap-2 md:items-center md:flex-nowrap md:flex-row" class="flex flex-col flex-wrap items-start gap-2 md:items-center md:flex-nowrap md:flex-row"

View File

@ -1,228 +0,0 @@
<script>
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
import ReportsFiltersDateRange from './Filters/DateRange.vue';
import ReportsFiltersDateGroupBy from './Filters/DateGroupBy.vue';
import ReportsFiltersAgents from './Filters/Agents.vue';
import ReportsFiltersLabels from './Filters/Labels.vue';
import ReportsFiltersInboxes from './Filters/Inboxes.vue';
import ReportsFiltersTeams from './Filters/Teams.vue';
import ReportsFiltersRatings from './Filters/Ratings.vue';
import subDays from 'date-fns/subDays';
import { DATE_RANGE_OPTIONS } from '../constants';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
export default {
components: {
WootDateRangePicker,
ReportsFiltersDateRange,
ReportsFiltersDateGroupBy,
ReportsFiltersAgents,
ReportsFiltersLabels,
ReportsFiltersInboxes,
ReportsFiltersTeams,
ReportsFiltersRatings,
ToggleSwitch,
},
props: {
showGroupByFilter: {
type: Boolean,
default: false,
},
showAgentsFilter: {
type: Boolean,
default: false,
},
showLabelsFilter: {
type: Boolean,
default: false,
},
showInboxFilter: {
type: Boolean,
default: false,
},
showRatingFilter: {
type: Boolean,
default: false,
},
showTeamFilter: {
type: Boolean,
default: false,
},
showBusinessHoursSwitch: {
type: Boolean,
default: true,
},
},
emits: ['filterChange'],
data() {
return {
// default value, need not be translated
selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
selectedGroupByFilter: null,
selectedLabel: null,
selectedInbox: null,
selectedTeam: null,
selectedRating: null,
selectedAgents: [],
customDateRange: [new Date(), new Date()],
businessHoursSelected: false,
};
},
computed: {
isDateRangeSelected() {
return (
this.selectedDateRange.id === DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE.id
);
},
isGroupByPossible() {
return this.selectedDateRange.id !== DATE_RANGE_OPTIONS.LAST_7_DAYS.id;
},
to() {
if (this.isDateRangeSelected) {
return getUnixEndOfDay(this.customDateRange[1]);
}
return getUnixEndOfDay(new Date());
},
from() {
if (this.isDateRangeSelected) {
return getUnixStartOfDay(this.customDateRange[0]);
}
const { offset } = this.selectedDateRange;
const fromDate = subDays(new Date(), offset);
return getUnixStartOfDay(fromDate);
},
validGroupOptions() {
return this.selectedDateRange.groupByOptions;
},
validGroupBy() {
if (!this.selectedGroupByFilter) {
return this.validGroupOptions[0];
}
const validIds = this.validGroupOptions.map(opt => opt.id);
if (validIds.includes(this.selectedGroupByFilter.id)) {
return this.selectedGroupByFilter;
}
return this.validGroupOptions[0];
},
},
mounted() {
this.emitChange();
},
methods: {
emitChange() {
const {
from,
to,
selectedGroupByFilter: groupBy,
businessHoursSelected: businessHours,
selectedAgents,
selectedLabel,
selectedInbox,
selectedTeam,
selectedRating,
} = this;
this.$emit('filterChange', {
from,
to,
groupBy,
businessHours,
selectedAgents,
selectedLabel,
selectedInbox,
selectedTeam,
selectedRating,
});
},
onDateRangeChange(selectedRange) {
this.selectedDateRange = selectedRange;
this.selectedGroupByFilter = this.validGroupBy;
this.emitChange();
},
onCustomDateRangeChange(value) {
this.customDateRange = value;
this.selectedGroupByFilter = this.validGroupBy;
this.emitChange();
},
onGroupingChange(payload) {
this.selectedGroupByFilter = payload;
this.emitChange();
},
handleAgentsFilterSelection(selectedAgents) {
this.selectedAgents = selectedAgents;
this.emitChange();
},
handleLabelsFilterSelection(selectedLabel) {
this.selectedLabel = selectedLabel;
this.emitChange();
},
handleInboxFilterSelection(selectedInbox) {
this.selectedInbox = selectedInbox;
this.emitChange();
},
handleTeamFilterSelection(selectedTeam) {
this.selectedTeam = selectedTeam;
this.emitChange();
},
handleRatingFilterSelection(selectedRating) {
this.selectedRating = selectedRating;
this.emitChange();
},
},
};
</script>
<template>
<div class="flex flex-col justify-between gap-3 md:flex-row">
<div
class="w-full grid gap-y-2 gap-x-1.5 grid-cols-[repeat(auto-fill,minmax(250px,1fr))]"
>
<ReportsFiltersDateRange @on-range-change="onDateRangeChange" />
<WootDateRangePicker
v-if="isDateRangeSelected"
show-range
class="no-margin auto-width"
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onCustomDateRangeChange"
/>
<ReportsFiltersDateGroupBy
v-if="showGroupByFilter && isGroupByPossible"
:valid-group-options="validGroupOptions"
:selected-option="selectedGroupByFilter"
@on-grouping-change="onGroupingChange"
/>
<ReportsFiltersAgents
v-if="showAgentsFilter"
@agents-filter-selection="handleAgentsFilterSelection"
/>
<ReportsFiltersLabels
v-if="showLabelsFilter"
@labels-filter-selection="handleLabelsFilterSelection"
/>
<ReportsFiltersTeams
v-if="showTeamFilter"
@team-filter-selection="handleTeamFilterSelection"
/>
<ReportsFiltersInboxes
v-if="showInboxFilter"
@inbox-filter-selection="handleInboxFilterSelection"
/>
<ReportsFiltersRatings
v-if="showRatingFilter"
@rating-filter-selection="handleRatingFilterSelection"
/>
</div>
<div v-if="showBusinessHoursSwitch" class="flex items-center">
<span class="mx-2 text-sm whitespace-nowrap">
{{ $t('REPORT.BUSINESS_HOURS') }}
</span>
<span>
<ToggleSwitch v-model="businessHoursSelected" @change="emitChange" />
</span>
</div>
</div>
</template>

View File

@ -1,47 +0,0 @@
<script>
import { mapGetters } from 'vuex';
export default {
name: 'ReportsFiltersAgents',
emits: ['agentsFilterSelection'],
data() {
return {
selectedOptions: [],
};
},
computed: {
...mapGetters({
options: 'agents/getAgents',
}),
},
mounted() {
this.$store.dispatch('agents/get');
},
methods: {
handleInput() {
this.$emit('agentsFilterSelection', this.selectedOptions);
},
},
};
</script>
<template>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedOptions"
class="no-margin"
:options="options"
track-by="id"
label="name"
multiple
:close-on-select="false"
:clear-on-select="false"
hide-selected
:placeholder="$t('CSAT_REPORTS.FILTERS.AGENTS.PLACEHOLDER')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
@update:model-value="handleInput"
/>
</div>
</template>

View File

@ -1,66 +0,0 @@
<script>
import { GROUP_BY_OPTIONS } from '../../constants';
export default {
name: 'ReportsFiltersDateGroupBy',
props: {
validGroupOptions: {
type: Array,
default: () => [GROUP_BY_OPTIONS.DAY],
},
selectedOption: {
type: Object,
default: () => GROUP_BY_OPTIONS.DAY,
},
},
emits: ['onGroupingChange'],
data() {
return {
currentSelectedFilter: null,
};
},
computed: {
translatedOptions() {
return this.validGroupOptions.map(option => ({
...option,
groupBy: this.$t(option.translationKey),
}));
},
},
watch: {
selectedOption: {
handler() {
this.currentSelectedFilter = {
...this.selectedOption,
groupBy: this.$t(this.selectedOption.translationKey),
};
},
immediate: true,
},
},
methods: {
changeFilterSelection(selectedFilter) {
this.groupByOptions = this.$emit('onGroupingChange', selectedFilter);
},
},
};
</script>
<template>
<div class="multiselect-wrap--small">
<p aria-hidden="true" class="hidden">
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedFilter"
class="no-margin"
track-by="id"
label="groupBy"
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
:options="translatedOptions"
:allow-empty="false"
:show-labels="false"
@select="changeFilterSelection"
/>
</div>
</template>

View File

@ -1,51 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { DATE_RANGE_OPTIONS } from '../../constants';
const emit = defineEmits(['onRangeChange']);
const { t } = useI18n();
const options = computed(() =>
Object.values(DATE_RANGE_OPTIONS).map(option => ({
...option,
name: t(option.translationKey),
}))
);
const selectedId = ref(Object.values(DATE_RANGE_OPTIONS)[0].id);
const selectedOption = computed({
get() {
return options.value.find(o => o.id === selectedId.value);
},
set(val) {
selectedId.value = val.id;
},
});
const updateRange = range => {
selectedOption.value = range;
emit('onRangeChange', range);
};
</script>
<template>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedOption"
class="no-margin"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:options="options"
:searchable="false"
:allow-empty="false"
@select="updateRange"
/>
</div>
</template>

View File

@ -1,42 +0,0 @@
<script>
import { mapGetters } from 'vuex';
export default {
name: 'ReportsFiltersInboxes',
emits: ['inboxFilterSelection'],
data() {
return {
selectedOption: null,
};
},
computed: {
...mapGetters({
options: 'inboxes/getInboxes',
}),
},
mounted() {
this.$store.dispatch('inboxes/get');
},
methods: {
handleInput() {
this.$emit('inboxFilterSelection', this.selectedOption);
},
},
};
</script>
<template>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedOption"
class="no-margin"
:placeholder="$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL')"
label="name"
track-by="id"
:options="options"
:option-height="24"
:show-labels="false"
@update:model-value="handleInput"
/>
</div>
</template>

View File

@ -1,66 +0,0 @@
<script>
import { mapGetters } from 'vuex';
export default {
name: 'ReportsFiltersLabels',
emits: ['labelsFilterSelection'],
data() {
return {
selectedOption: null,
};
},
computed: {
...mapGetters({
options: 'labels/getLabels',
}),
},
mounted() {
this.$store.dispatch('labels/get');
},
methods: {
handleInput() {
this.$emit('labelsFilterSelection', this.selectedOption);
},
},
};
</script>
<template>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedOption"
class="no-margin"
:placeholder="$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL')"
label="title"
track-by="id"
:options="options"
:option-height="24"
:show-labels="false"
@update:model-value="handleInput"
>
<template #singleLabel="props">
<div class="flex items-center min-w-0 gap-2">
<div
:style="{ backgroundColor: props.option.color }"
class="w-5 h-5 rounded-full"
/>
<span class="my-0 text-n-slate-12">
{{ props.option.title }}
</span>
</div>
</template>
<template #option="props">
<div class="flex items-center gap-2">
<div
:style="{ backgroundColor: props.option.color }"
class="flex-shrink-0 w-5 h-5 border border-solid rounded-full border-n-weak"
/>
<span class="my-0 text-n-slate-12 truncate min-w-0">
{{ props.option.title }}
</span>
</div>
</template>
</multiselect>
</div>
</template>

View File

@ -1,40 +0,0 @@
<script>
import { CSAT_RATINGS } from 'shared/constants/messages';
export default {
name: 'ReportFiltersRatings',
emits: ['ratingFilterSelection'],
data() {
const translatedOptions = CSAT_RATINGS.reverse().map(option => ({
...option,
label: this.$t(option.translationKey),
}));
return {
selectedOption: null,
options: translatedOptions,
};
},
methods: {
handleInput(selectedRating) {
this.$emit('ratingFilterSelection', selectedRating);
},
},
};
</script>
<template>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedOption"
class="no-margin"
:option-height="24"
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
:options="options"
:show-labels="false"
track-by="value"
label="label"
@update:model-value="handleInput"
/>
</div>
</template>

View File

@ -1,42 +0,0 @@
<script>
import { mapGetters } from 'vuex';
export default {
name: 'ReportsFiltersTeams',
emits: ['teamFilterSelection'],
data() {
return {
selectedOption: null,
};
},
computed: {
...mapGetters({
options: 'teams/getTeams',
}),
},
mounted() {
this.$store.dispatch('teams/get');
},
methods: {
handleInput() {
this.$emit('teamFilterSelection', this.selectedOption);
},
},
};
</script>
<template>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedOption"
class="no-margin"
:placeholder="$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL')"
label="name"
track-by="id"
:options="options"
:option-height="24"
:show-labels="false"
@update:model-value="handleInput"
/>
</div>
</template>

View File

@ -8,8 +8,8 @@ const props = defineProps({
required: true, required: true,
}, },
id: { id: {
type: Number, type: [Number, null],
required: true, default: null,
}, },
type: { type: {
type: String, type: String,
@ -35,6 +35,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showClearFilter: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@ -60,7 +64,7 @@ const closeDropdown = () => emit('closeDropdown');
<FilterListDropdown <FilterListDropdown
v-if="options" v-if="options"
v-on-clickaway="closeDropdown" v-on-clickaway="closeDropdown"
show-clear-filter :show-clear-filter="showClearFilter"
:list-items="options" :list-items="options"
:active-filter-id="id" :active-filter-id="id"
:input-placeholder="placeholder" :input-placeholder="placeholder"

View File

@ -0,0 +1,102 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import subDays from 'date-fns/subDays';
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import {
generateReportURLParams,
parseReportURLParams,
} from '../helpers/reportFilterHelper';
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
const emit = defineEmits(['filterChange']);
const route = useRoute();
const router = useRouter();
const customDateRange = ref([subDays(new Date(), 6), new Date()]);
const selectedDateRange = ref(DATE_RANGE_TYPES.LAST_7_DAYS);
const businessHoursSelected = ref(false);
const updateURLParams = () => {
const params = generateReportURLParams({
from: getUnixStartOfDay(customDateRange.value[0]),
to: getUnixEndOfDay(customDateRange.value[1]),
businessHours: businessHoursSelected.value,
range: selectedDateRange.value,
});
router.replace({ query: { ...params } });
};
const emitChange = () => {
updateURLParams();
emit('filterChange', {
from: getUnixStartOfDay(customDateRange.value[0]),
to: getUnixEndOfDay(customDateRange.value[1]),
businessHours: businessHoursSelected.value,
});
};
const onDateRangeChange = value => {
const [startDate, endDate, rangeType] = value;
customDateRange.value = [startDate, endDate];
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
emitChange();
};
const onBusinessHoursToggle = () => {
emitChange();
};
const initializeFromURL = () => {
const urlParams = parseReportURLParams(route.query);
// Set the range type first
if (urlParams.range) {
selectedDateRange.value = urlParams.range;
}
// Restore dates from URL if available
if (urlParams.from && urlParams.to) {
customDateRange.value = [
new Date(urlParams.from * 1000),
new Date(urlParams.to * 1000),
];
}
if (urlParams.businessHours) {
businessHoursSelected.value = urlParams.businessHours;
}
};
onMounted(() => {
initializeFromURL();
emitChange();
});
</script>
<template>
<div class="flex flex-col justify-between gap-3 md:flex-row">
<div class="flex flex-col flex-wrap items-start gap-2 md:flex-row">
<WootDatePicker
v-model:date-range="customDateRange"
v-model:range-type="selectedDateRange"
@date-range-changed="onDateRangeChange"
/>
</div>
<div class="flex items-center">
<span class="mx-2 text-sm whitespace-nowrap">
{{ $t('REPORT.BUSINESS_HOURS') }}
</span>
<span>
<ToggleSwitch
v-model="businessHoursSelected"
@change="onBusinessHoursToggle"
/>
</span>
</div>
</div>
</template>

View File

@ -1,349 +1,382 @@
<script> <script setup>
import endOfDay from 'date-fns/endOfDay'; import { ref, computed, onMounted } from 'vue';
import getUnixTime from 'date-fns/getUnixTime'; import { useI18n } from 'vue-i18n';
import startOfDay from 'date-fns/startOfDay'; import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import subDays from 'date-fns/subDays'; import subDays from 'date-fns/subDays';
import Avatar from 'next/avatar/Avatar.vue'; import differenceInDays from 'date-fns/differenceInDays';
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue'; import ActiveFilterChip from './Filters/v3/ActiveFilterChip.vue';
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue'; import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import { GROUP_BY_FILTER } from '../constants'; import { GROUP_BY_FILTER } from '../constants';
const CUSTOM_DATE_RANGE_ID = 5; import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
import {
generateReportURLParams,
parseReportURLParams,
} from '../helpers/reportFilterHelper';
export default { const props = defineProps({
components: { filterType: {
WootDateRangePicker, type: String,
Avatar, required: false,
ToggleSwitch, default: '',
validator: value =>
['teams', 'inboxes', 'labels', 'agents', ''].includes(value),
}, },
props: { selectedItem: {
currentFilter: { type: Object,
type: Object, default: null,
default: () => null,
},
filterItemsList: {
type: Array,
default: () => [],
},
groupByFilterItemsList: {
type: Array,
default: () => [],
},
type: {
type: String,
default: 'agent',
},
selectedGroupByFilter: {
type: Object,
default: () => {},
},
}, },
emits: [ showGroupBy: {
'businessHoursToggle', type: Boolean,
'dateRangeChange', default: true,
'filterChange',
'groupByFilterChange',
],
data() {
return {
currentSelectedFilter: this.currentFilter || null,
currentDateRangeSelection: {
id: 0,
name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
},
customDateRange: [new Date(), new Date()],
currentSelectedGroupByFilter: null,
businessHoursSelected: false,
};
}, },
computed: { showBusinessHours: {
dateRange() { type: Boolean,
return [ default: true,
{ id: 0, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS') },
{ id: 1, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS') },
{ id: 2, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_3_MONTHS') },
{ id: 3, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_6_MONTHS') },
{ id: 4, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_YEAR') },
{ id: 5, name: this.$t('REPORT.DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE') },
];
},
isDateRangeSelected() {
return this.currentDateRangeSelection.id === CUSTOM_DATE_RANGE_ID;
},
to() {
if (this.isDateRangeSelected) {
return this.toCustomDate(this.customDateRange[1]);
}
return this.toCustomDate(new Date());
},
from() {
if (this.isDateRangeSelected) {
return this.fromCustomDate(this.customDateRange[0]);
}
const dateRange = {
0: 6,
1: 29,
2: 89,
3: 179,
4: 364,
};
const diff = dateRange[this.currentDateRangeSelection.id];
const fromDate = subDays(new Date(), diff);
return this.fromCustomDate(fromDate);
},
multiselectLabel() {
const typeLabels = {
agent: this.$t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL'),
label: this.$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL'),
inbox: this.$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL'),
team: this.$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL'),
};
return typeLabels[this.type] || this.$t('FORMS.MULTISELECT.SELECT_ONE');
},
groupBy() {
if (this.isDateRangeSelected) {
return GROUP_BY_FILTER[4].period;
}
const groupRange = {
0: GROUP_BY_FILTER[1].period,
1: GROUP_BY_FILTER[2].period,
2: GROUP_BY_FILTER[3].period,
3: GROUP_BY_FILTER[3].period,
4: GROUP_BY_FILTER[4].period,
};
return groupRange[this.currentDateRangeSelection.id];
},
notLast7Days() {
return this.groupBy !== GROUP_BY_FILTER[1].period;
},
}, },
watch: { showEntityFilter: {
filterItemsList(val) { type: Boolean,
this.currentSelectedFilter = !this.currentFilter default: true,
? val[0]
: this.currentFilter;
this.changeFilterSelection();
},
groupByFilterItemsList() {
this.currentSelectedGroupByFilter = this.selectedGroupByFilter;
},
},
mounted() {
this.onDateRangeChange();
},
methods: {
onDateRangeChange() {
this.$emit('dateRangeChange', {
from: this.from,
to: this.to,
groupBy: this.groupBy,
});
},
onBusinessHoursToggle() {
this.$emit('businessHoursToggle', this.businessHoursSelected);
},
fromCustomDate(date) {
return getUnixTime(startOfDay(date));
},
toCustomDate(date) {
return getUnixTime(endOfDay(date));
},
changeDateSelection(selectedRange) {
this.currentDateRangeSelection = selectedRange;
this.onDateRangeChange();
},
changeFilterSelection() {
this.$emit('filterChange', this.currentSelectedFilter);
},
onChange(value) {
this.customDateRange = value;
this.onDateRangeChange();
},
changeGroupByFilterSelection() {
this.$emit('groupByFilterChange', this.currentSelectedGroupByFilter);
},
}, },
});
const emit = defineEmits(['filterChange']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const buildReportFilterList = (items, type) => {
if (!Array.isArray(items)) return [];
return items.map(item => ({
id: item.id,
name: item.name || item.title,
type,
}));
}; };
const getReportFilterKey = filterType => {
const keyMap = {
teams: 'team_id',
inboxes: 'inbox_id',
labels: 'label_id',
agents: 'agent_id',
};
return keyMap[filterType] || '';
};
const getFilterKey = () => getReportFilterKey(props.filterType);
const showSubDropdownMenu = ref(false);
const showGroupByDropdown = ref(false);
const activeFilterType = ref('');
const customDateRange = ref([subDays(new Date(), 6), new Date()]);
const selectedDateRange = ref(DATE_RANGE_TYPES.LAST_7_DAYS);
const businessHoursSelected = ref(false);
const groupBy = ref(GROUP_BY_FILTER[1]);
const groupByfilterItemsList = ref([{ id: 1, name: 'Day' }]);
const appliedFilters = ref(
props.showEntityFilter
? { [getFilterKey()]: props.selectedItem?.id || null }
: {}
);
const filterSource = computed(() => {
const sources = {
teams: store.getters['teams/getTeams'],
inboxes: store.getters['inboxes/getInboxes'],
labels: store.getters['labels/getLabels'],
agents: store.getters['agents/getAgents'],
};
return sources[props.filterType] || [];
});
const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
const daysDifference = computed(() => {
return differenceInDays(customDateRange.value[1], customDateRange.value[0]);
});
const isGroupByPossible = computed(() => {
return props.showGroupBy && daysDifference.value >= 29;
});
const GROUP_BY_OPTIONS = computed(() => ({
WEEK: [
{ id: 1, name: t('REPORT.GROUPING_OPTIONS.DAY') },
{ id: 2, name: t('REPORT.GROUPING_OPTIONS.WEEK') },
],
MONTH: [
{ id: 1, name: t('REPORT.GROUPING_OPTIONS.DAY') },
{ id: 2, name: t('REPORT.GROUPING_OPTIONS.WEEK') },
{ id: 3, name: t('REPORT.GROUPING_OPTIONS.MONTH') },
],
YEAR: [
{ id: 2, name: t('REPORT.GROUPING_OPTIONS.WEEK') },
{ id: 3, name: t('REPORT.GROUPING_OPTIONS.MONTH') },
{ id: 4, name: t('REPORT.GROUPING_OPTIONS.YEAR') },
],
}));
const fetchFilterItems = () => {
const days = daysDifference.value;
if (days >= 364) return GROUP_BY_OPTIONS.value.YEAR;
if (days >= 90) return GROUP_BY_OPTIONS.value.MONTH;
if (days >= 29) return GROUP_BY_OPTIONS.value.WEEK;
return GROUP_BY_OPTIONS.value.WEEK;
};
const filterOptions = computed(() =>
buildReportFilterList(filterSource.value, props.filterType)
);
const filterPlaceholder = computed(() => {
const placeholders = {
teams: 'TEAM_REPORTS.FILTERS.INPUT_PLACEHOLDER.TEAMS',
inboxes: 'INBOX_REPORTS.FILTERS.INPUT_PLACEHOLDER.INBOXES',
labels: 'LABEL_REPORTS.FILTERS.INPUT_PLACEHOLDER.LABELS',
agents: 'AGENT_REPORTS.FILTERS.INPUT_PLACEHOLDER.AGENTS',
};
return t(placeholders[props.filterType] || '');
});
const defaultFilterLabel = computed(() => {
const labelKeys = {
teams: 'TEAM_REPORTS.FILTER_DROPDOWN_LABEL',
inboxes: 'INBOX_REPORTS.FILTER_DROPDOWN_LABEL',
labels: 'LABEL_REPORTS.FILTER_DROPDOWN_LABEL',
agents: 'AGENT_REPORTS.FILTER_DROPDOWN_LABEL',
};
return t(labelKeys[props.filterType] || 'FORMS.MULTISELECT.SELECT_ONE');
});
const selectedFilterName = computed(() => {
const filterKey = getFilterKey();
const selectedId = appliedFilters.value[filterKey];
if (!selectedId) {
return defaultFilterLabel.value;
}
const selectedItem = filterOptions.value.find(item => item.id === selectedId);
return selectedItem?.name || defaultFilterLabel.value;
});
const updateURLParams = () => {
const params = generateReportURLParams({
from: from.value,
to: to.value,
businessHours: businessHoursSelected.value,
groupBy: isGroupByPossible.value ? groupBy.value.id : null,
range: selectedDateRange.value,
});
router.replace({ query: { ...params } });
};
const emitChange = () => {
const payload = {
from: from.value,
to: to.value,
businessHours: businessHoursSelected.value,
};
if (props.showGroupBy) {
// Always emit groupBy, default to day when range is too short
payload.groupBy = isGroupByPossible.value
? groupBy.value
: GROUP_BY_FILTER[1];
}
if (props.showEntityFilter) {
const filterKey = getFilterKey();
const selectedValue = appliedFilters.value[filterKey];
if (selectedValue) {
payload[props.filterType] =
props.filterType === 'agents'
? [{ id: selectedValue }]
: { id: selectedValue };
}
}
updateURLParams();
emit('filterChange', payload);
};
const closeActiveFilterDropdown = () => {
showSubDropdownMenu.value = false;
activeFilterType.value = '';
};
const openActiveFilterDropdown = filterType => {
showGroupByDropdown.value = false;
activeFilterType.value = filterType;
showSubDropdownMenu.value = !showSubDropdownMenu.value;
};
const addFilter = item => {
const filterKey = getFilterKey();
appliedFilters.value[filterKey] = item.id;
closeActiveFilterDropdown();
emitChange();
// Navigate to the new entity's route
const routeNameMap = {
teams: 'team_reports_show',
inboxes: 'inbox_reports_show',
labels: 'label_reports_show',
agents: 'agent_reports_show',
};
const routeName = routeNameMap[props.filterType];
if (routeName) {
router.push({
name: routeName,
params: { ...route.params, id: item.id },
query: route.query,
});
}
};
const onDateRangeChange = value => {
const [startDate, endDate, rangeType] = value;
customDateRange.value = [startDate, endDate];
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
groupByfilterItemsList.value = fetchFilterItems();
const filterItems = groupByfilterItemsList.value.filter(
item => item.id === groupBy.value.id
);
if (filterItems.length === 0) {
groupBy.value = GROUP_BY_FILTER[groupByfilterItemsList.value[0].id];
}
emitChange();
};
const onBusinessHoursToggle = () => {
emitChange();
};
const onGroupByFilterChange = payload => {
groupBy.value = GROUP_BY_FILTER[payload.id];
showGroupByDropdown.value = false;
emitChange();
};
const toggleGroupByDropdown = () => {
showGroupByDropdown.value = !showGroupByDropdown.value;
};
const closeGroupByDropdown = () => {
showGroupByDropdown.value = false;
};
const initializeFromURL = () => {
const urlParams = parseReportURLParams(route.query);
// Set the range type first
if (urlParams.range) {
selectedDateRange.value = urlParams.range;
}
// Restore dates from URL if available
if (urlParams.from && urlParams.to) {
customDateRange.value = [
new Date(urlParams.from * 1000),
new Date(urlParams.to * 1000),
];
}
if (urlParams.businessHours) {
businessHoursSelected.value = urlParams.businessHours;
}
if (urlParams.groupBy) {
const groupByValue = GROUP_BY_FILTER[urlParams.groupBy];
if (groupByValue) {
groupBy.value = groupByValue;
}
}
// Initialize entity filter from route params (not URL query)
if (props.showEntityFilter && route.params.id) {
const filterKey = getFilterKey();
appliedFilters.value[filterKey] = Number(route.params.id);
}
};
onMounted(() => {
initializeFromURL();
groupByfilterItemsList.value = fetchFilterItems();
emitChange();
});
</script> </script>
<template> <template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-y-0.5 gap-x-2"> <div class="flex flex-col w-full gap-3 lg:flex-row">
<div v-if="type === 'agent'" class="multiselect-wrap--small"> <WootDatePicker
<p class="mb-2 text-xs font-medium"> v-model:date-range="customDateRange"
{{ $t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL') }} v-model:range-type="selectedDateRange"
</p> @date-range-changed="onDateRangeChange"
<multiselect />
v-model="currentSelectedFilter"
:placeholder="multiselectLabel"
label="name"
track-by="id"
:options="filterItemsList"
:option-height="24"
:show-labels="false"
@update:model-value="changeFilterSelection"
>
<template #singleLabel="props">
<div class="flex min-w-0 items-center gap-2">
<Avatar
:src="props.option.thumbnail"
:status="props.option.availability_status"
:name="props.option.name"
:size="22"
hide-offline-status
rounded-full
/>
<span class="my-0 text-n-slate-12 truncate">{{
props.option.name
}}</span>
</div>
</template>
<template #options="props">
<div class="flex items-center gap-2">
<Avatar
:src="props.option.thumbnail"
:status="props.option.availability_status"
:name="props.option.name"
:size="22"
hide-offline-status
rounded-full
/>
<p class="my-0 text-n-slate-12">
{{ props.option.name }}
</p>
</div>
</template>
</multiselect>
</div>
<div v-else-if="type === 'label'" class="multiselect-wrap--small"> <div class="flex gap-2 items-center w-full">
<p class="mb-2 text-xs font-medium"> <ActiveFilterChip
{{ $t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL') }} v-if="showEntityFilter"
</p> :id="appliedFilters[getFilterKey()]"
<multiselect :name="selectedFilterName"
v-model="currentSelectedFilter" :type="filterType"
:placeholder="multiselectLabel" :options="filterOptions"
label="title" :active-filter-type="activeFilterType"
track-by="id" :show-menu="showSubDropdownMenu"
:options="filterItemsList" :placeholder="filterPlaceholder"
:option-height="24" :show-clear-filter="false"
:show-labels="false" enable-search
@update:model-value="changeFilterSelection" @toggle-dropdown="openActiveFilterDropdown"
> @close-dropdown="closeActiveFilterDropdown"
<template #singleLabel="props"> @add-filter="addFilter"
<div class="flex items-center min-w-0 gap-2">
<div
:style="{ backgroundColor: props.option.color }"
class="w-5 h-5 rounded-full"
/>
<span class="my-0 text-n-slate-12 truncate">
{{ props.option.title }}
</span>
</div>
</template>
<template #option="props">
<div class="flex items-center min-w-0 gap-2">
<div
:style="{ backgroundColor: props.option.color }"
class="flex-shrink-0 w-5 h-5 border border-solid rounded-full border-n-weak"
/>
<span class="my-0 text-n-slate-12 truncate">
{{ props.option.title }}
</span>
</div>
</template>
</multiselect>
</div>
<div v-else class="multiselect-wrap--small">
<p class="mb-2 text-xs font-medium">
<template v-if="type === 'inbox'">
{{ $t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL') }}
</template>
<template v-else-if="type === 'team'">
{{ $t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL') }}
</template>
<template v-else>
{{ $t('FORMS.MULTISELECT.SELECT_ONE') }}
</template>
</p>
<multiselect
v-model="currentSelectedFilter"
track-by="id"
label="name"
:placeholder="multiselectLabel"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:options="filterItemsList"
:searchable="false"
:allow-empty="false"
@update:model-value="changeFilterSelection"
/> />
</div>
<div class="multiselect-wrap--small"> <ActiveFilterChip
<p class="mb-2 text-xs font-medium"> v-if="isGroupByPossible"
{{ $t('REPORT.DURATION_FILTER_LABEL') }} :id="groupBy?.id"
</p> :name="
<multiselect groupByfilterItemsList.find(item => item.id === groupBy?.id)?.name ||
v-model="currentDateRangeSelection" $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')
track-by="name" "
label="name" type="groupBy"
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')" :options="groupByfilterItemsList"
selected-label :active-filter-type="showGroupByDropdown ? 'groupBy' : ''"
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" :show-menu="showGroupByDropdown"
deselect-label=""
:options="dateRange"
:searchable="false"
:allow-empty="false"
@select="changeDateSelection"
/>
</div>
<div
class="flex items-center h-10 self-center order-5 md:order-2 md:justify-self-end"
>
<span class="mr-2 text-sm whitespace-nowrap">
{{ $t('REPORT.BUSINESS_HOURS') }}
</span>
<span>
<ToggleSwitch
v-model="businessHoursSelected"
@change="onBusinessHoursToggle"
/>
</span>
</div>
<div v-if="isDateRangeSelected" class="order-3 md:order-4">
<p class="mb-2 text-xs font-medium">
{{ $t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER') }}
</p>
<WootDateRangePicker
show-range
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
class="auto-width"
@change="onChange"
/>
</div>
<div v-if="notLast7Days" class="multiselect-wrap--small order-4 md:order-5">
<p class="mb-2 text-xs font-medium">
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedGroupByFilter"
track-by="id"
label="groupBy"
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')" :placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
:options="groupByFilterItemsList" :enable-search="false"
:allow-empty="false" :show-clear-filter="false"
:show-labels="false" @toggle-dropdown="toggleGroupByDropdown"
@update:model-value="changeGroupByFilterSelection" @close-dropdown="closeGroupByDropdown"
@add-filter="onGroupByFilterChange"
@remove-filter="() => {}"
/> />
<div
v-if="showBusinessHours"
class="flex items-center flex-shrink-0 ltr:ml-auto rtl:mr-auto"
>
<span class="mx-2 text-sm whitespace-nowrap">
{{ $t('REPORT.BUSINESS_HOURS') }}
</span>
<span>
<ToggleSwitch
v-model="businessHoursSelected"
@change="onBusinessHoursToggle"
/>
</span>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,155 +1,201 @@
<script> <script setup>
import { mapGetters } from 'vuex'; import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { import {
buildFilterList, buildFilterList,
getActiveFilter, getActiveFilter,
getFilterType, getFilterType,
} from './helpers/SLAFilterHelpers'; } from './helpers/SLAFilterHelpers';
import {
parseFilterURLParams,
generateCompleteURLParams,
} from '../../helpers/reportFilterHelper';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue'; import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue'; import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
import AddFilterChip from '../Filters/v3/AddFilterChip.vue'; import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
export default { const emit = defineEmits(['filterChange']);
components: {
FilterButton, const { t } = useI18n();
ActiveFilterChip, const store = useStore();
AddFilterChip, const route = useRoute();
}, const router = useRouter();
emits: ['filterChange'],
data() { const showDropdownMenu = ref(false);
const showSubDropdownMenu = ref(false);
const activeFilterType = ref('');
const appliedFilters = ref({
assigned_agent_id: null,
inbox_id: null,
team_id: null,
sla_policy_id: null,
label_list: null,
});
const agents = computed(() => store.getters['agents/getAgents']);
const inboxes = computed(() => store.getters['inboxes/getInboxes']);
const teams = computed(() => store.getters['teams/getTeams']);
const labels = computed(() => store.getters['labels/getLabels']);
const sla = computed(() => store.getters['sla/getSLA']);
const filterListMenuItems = computed(() => {
const filterTypes = [
{ id: '1', name: t('SLA_REPORTS.DROPDOWN.SLA'), type: 'sla' },
{ id: '2', name: t('SLA_REPORTS.DROPDOWN.INBOXES'), type: 'inboxes' },
{ id: '3', name: t('SLA_REPORTS.DROPDOWN.AGENTS'), type: 'agents' },
{ id: '4', name: t('SLA_REPORTS.DROPDOWN.TEAMS'), type: 'teams' },
{ id: '5', name: t('SLA_REPORTS.DROPDOWN.LABELS'), type: 'labels' },
];
const activeFilterKeys = Object.keys(appliedFilters.value).filter(
key => appliedFilters.value[key]
);
const activeFilterTypes = activeFilterKeys.map(key =>
getFilterType(key, 'keyToType')
);
const sources = {
agents: agents.value,
inboxes: inboxes.value,
teams: teams.value,
labels: labels.value,
sla: sla.value,
};
return filterTypes
.filter(({ type }) => !activeFilterTypes.includes(type))
.map(({ id, name, type }) => ({
id,
name,
type,
options: buildFilterList(sources[type], type),
}));
});
const activeFilters = computed(() => {
const activeKeys = Object.keys(appliedFilters.value).filter(
key => appliedFilters.value[key]
);
const sources = {
agents: agents.value,
inboxes: inboxes.value,
teams: teams.value,
labels: labels.value,
sla: sla.value,
};
return activeKeys.map(key => {
const filterType = getFilterType(key, 'keyToType');
const item = getActiveFilter(
sources[filterType],
filterType,
appliedFilters.value[key]
);
return { return {
showDropdownMenu: false, id: item.id,
showSubDropdownMenu: false, name: filterType === 'labels' ? item.title : item.name,
activeFilterType: '', type: filterType,
appliedFilters: { options: buildFilterList(sources[filterType], filterType),
assigned_agent_id: null,
inbox_id: null,
team_id: null,
sla_policy_id: null,
label_list: null,
},
}; };
}, });
computed: { });
...mapGetters({
agents: 'agents/getAgents', const hasActiveFilters = computed(() =>
inboxes: 'inboxes/getInboxes', Object.values(appliedFilters.value).some(value => value !== null)
teams: 'teams/getTeams', );
labels: 'labels/getLabels',
sla: 'sla/getSLA', const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
}),
filterListMenuItems() { const updateURLParams = () => {
const filterTypes = [ const params = generateCompleteURLParams({
{ id: '1', name: this.$t('SLA_REPORTS.DROPDOWN.SLA'), type: 'sla' }, from: route.query.from,
{ to: route.query.to,
id: '2', range: route.query.range,
name: this.$t('SLA_REPORTS.DROPDOWN.INBOXES'), filters: {
type: 'inboxes', agent_id: appliedFilters.value.assigned_agent_id,
}, inbox_id: appliedFilters.value.inbox_id,
{ team_id: appliedFilters.value.team_id,
id: '3', sla_policy_id: appliedFilters.value.sla_policy_id,
name: this.$t('SLA_REPORTS.DROPDOWN.AGENTS'), label: appliedFilters.value.label_list,
type: 'agents',
},
{ id: '4', name: this.$t('SLA_REPORTS.DROPDOWN.TEAMS'), type: 'teams' },
{
id: '5',
name: this.$t('SLA_REPORTS.DROPDOWN.LABELS'),
type: 'labels',
},
];
// Filter out the active filters from the filter list
// We only want to show the filters that are not already applied
// In the add filter dropdown
const activeFilters = Object.keys(this.appliedFilters).filter(
key => this.appliedFilters[key]
);
const activeFilterTypes = activeFilters.map(key =>
getFilterType(key, 'keyToType')
);
return filterTypes
.filter(({ type }) => !activeFilterTypes.includes(type))
.map(({ id, name, type }) => ({
id,
name,
type,
options: buildFilterList(this[type], type),
}));
}, },
activeFilters() { });
// Get the active filters from the applied filters router.replace({ query: params });
// and return the filter name, type and options
const activeKey = Object.keys(this.appliedFilters).filter(
key => this.appliedFilters[key]
);
return activeKey.map(key => {
const filterType = getFilterType(key, 'keyToType');
const item = getActiveFilter(
this[filterType],
filterType,
this.appliedFilters[key]
);
return {
id: item.id,
name: filterType === 'labels' ? item.title : item.name,
type: filterType,
options: buildFilterList(this[filterType], filterType),
};
});
},
hasActiveFilters() {
return Object.values(this.appliedFilters).some(value => value !== null);
},
isAllFilterSelected() {
return !this.filterListMenuItems.length;
},
},
methods: {
addFilter(item) {
const { type, id, name } = item;
const filterKey = getFilterType(type, 'typeToKey');
this.appliedFilters[filterKey] = type === 'labels' ? name : id;
this.$emit('filterChange', this.appliedFilters);
this.resetDropdown();
},
removeFilter(type) {
const filterKey = getFilterType(type, 'typeToKey');
this.appliedFilters[filterKey] = null;
this.$emit('filterChange', this.appliedFilters);
},
clearAllFilters() {
this.appliedFilters = {
assigned_agent_id: null,
inbox_id: null,
team_id: null,
sla_policy_id: null,
label_list: null,
};
this.$emit('filterChange', this.appliedFilters);
this.resetDropdown();
},
showDropdown() {
this.showSubDropdownMenu = false;
this.showDropdownMenu = !this.showDropdownMenu;
},
closeDropdown() {
this.showDropdownMenu = false;
},
openActiveFilterDropdown(filterType) {
this.closeDropdown();
this.activeFilterType = filterType;
this.showSubDropdownMenu = !this.showSubDropdownMenu;
},
closeActiveFilterDropdown() {
this.activeFilterType = '';
this.showSubDropdownMenu = false;
},
resetDropdown() {
this.closeDropdown();
this.closeActiveFilterDropdown();
},
},
}; };
const emitChange = () => {
updateURLParams();
emit('filterChange', appliedFilters.value);
};
const showDropdown = () => {
showSubDropdownMenu.value = false;
showDropdownMenu.value = !showDropdownMenu.value;
};
const closeDropdown = () => {
showDropdownMenu.value = false;
};
const openActiveFilterDropdown = filterType => {
closeDropdown();
activeFilterType.value = filterType;
showSubDropdownMenu.value = !showSubDropdownMenu.value;
};
const closeActiveFilterDropdown = () => {
activeFilterType.value = '';
showSubDropdownMenu.value = false;
};
const resetDropdown = () => {
closeDropdown();
closeActiveFilterDropdown();
};
const addFilter = item => {
const { type, id, name } = item;
const filterKey = getFilterType(type, 'typeToKey');
appliedFilters.value[filterKey] = type === 'labels' ? name : id;
emitChange();
resetDropdown();
};
const removeFilter = type => {
const filterKey = getFilterType(type, 'typeToKey');
appliedFilters.value[filterKey] = null;
emitChange();
};
const clearAllFilters = () => {
appliedFilters.value = {
assigned_agent_id: null,
inbox_id: null,
team_id: null,
sla_policy_id: null,
label_list: null,
};
emitChange();
resetDropdown();
};
const initializeFromURL = () => {
const urlFilters = parseFilterURLParams(route.query);
appliedFilters.value.assigned_agent_id = urlFilters.agent_id;
appliedFilters.value.inbox_id = urlFilters.inbox_id;
appliedFilters.value.team_id = urlFilters.team_id;
appliedFilters.value.sla_policy_id = urlFilters.sla_policy_id;
appliedFilters.value.label_list = urlFilters.label;
};
onMounted(() => {
initializeFromURL();
if (hasActiveFilters.value) {
emitChange();
}
});
</script> </script>
<template> <template>

View File

@ -1,73 +1,98 @@
<script> <script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import SLAFilter from '../SLA/SLAFilter.vue'; import SLAFilter from '../SLA/SLAFilter.vue';
import subDays from 'date-fns/subDays'; import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
import { DATE_RANGE_OPTIONS } from '../../constants'; import { subDays, fromUnixTime } from 'date-fns';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper'; import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import {
generateReportURLParams,
parseReportURLParams,
parseFilterURLParams,
} from '../../helpers/reportFilterHelper';
export default { const emit = defineEmits(['filterChange']);
components: {
SLAFilter,
},
emits: ['filterChange'],
data() { const route = useRoute();
return { const router = useRouter();
selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
selectedGroupByFilter: null, // Initialize from URL params immediately
customDateRange: [new Date(), new Date()], const urlParams = parseReportURLParams(route.query);
}; const initialDateRange =
}, urlParams.from && urlParams.to
computed: { ? [fromUnixTime(urlParams.from), fromUnixTime(urlParams.to)]
to() { : [subDays(new Date(), 6), new Date()];
return getUnixEndOfDay(this.customDateRange[1]);
}, const selectedDateRange = ref(urlParams.range || 'last7days');
from() { const selectedGroupByFilter = ref(null);
return getUnixStartOfDay(this.customDateRange[0]); const customDateRange = ref(initialDateRange);
},
}, const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
watch: { const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
businessHoursSelected() {
this.emitChange(); const updateURLParams = () => {
}, const dateParams = generateReportURLParams({
}, from: from.value,
mounted() { to: to.value,
this.setInitialRange(); range: selectedDateRange.value,
}, });
methods: {
setInitialRange() { const filterParams = parseFilterURLParams(route.query);
const { offset } = this.selectedDateRange; const params = { ...dateParams };
const fromDate = subDays(new Date(), offset);
const from = getUnixStartOfDay(fromDate); if (filterParams.agent_id) params.agent_id = filterParams.agent_id;
const to = getUnixEndOfDay(new Date()); if (filterParams.inbox_id) params.inbox_id = filterParams.inbox_id;
this.$emit('filterChange', { if (filterParams.team_id) params.team_id = filterParams.team_id;
from, if (filterParams.sla_policy_id)
to, params.sla_policy_id = filterParams.sla_policy_id;
...this.selectedGroupByFilter, if (filterParams.label) params.label = filterParams.label;
});
}, router.replace({ query: params });
emitChange() {
const { from, to } = this;
this.$emit('filterChange', {
from,
to,
...this.selectedGroupByFilter,
});
},
emitFilterChange(params) {
this.selectedGroupByFilter = params;
this.emitChange();
},
onDateRangeChange(value) {
this.customDateRange = value;
this.emitChange();
},
},
}; };
const emitChange = () => {
updateURLParams();
emit('filterChange', {
from: from.value,
to: to.value,
...selectedGroupByFilter.value,
});
};
const emitFilterChange = params => {
selectedGroupByFilter.value = params;
emit('filterChange', {
from: from.value,
to: to.value,
...selectedGroupByFilter.value,
});
};
const onDateRangeChange = ([startDate, endDate, rangeType]) => {
customDateRange.value = [startDate, endDate];
selectedDateRange.value = rangeType;
emitChange();
};
const setInitialRange = () => {
customDateRange.value = [subDays(new Date(), 6), new Date()];
emitChange();
};
onMounted(() => {
if (!route.query.from || !route.query.to) {
setInitialRange();
}
});
</script> </script>
<template> <template>
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row"> <div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
<woot-date-picker @date-range-changed="onDateRangeChange" /> <WootDatePicker
v-model:date-range="customDateRange"
v-model:range-type="selectedDateRange"
@date-range-changed="onDateRangeChange"
/>
<SLAFilter @filter-change="emitFilterChange" /> <SLAFilter @filter-change="emitFilterChange" />
</div> </div>
</template> </template>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import ReportFilterSelector from './FilterSelector.vue'; import OverviewReportFilters from './OverviewReportFilters.vue';
import { formatTime } from '@chatwoot/utils'; import { formatTime } from '@chatwoot/utils';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import Table from 'dashboard/components/table/Table.vue'; import Table from 'dashboard/components/table/Table.vue';
@ -178,7 +178,7 @@ defineExpose({ downloadReports });
</script> </script>
<template> <template>
<ReportFilterSelector @filter-change="onFilterChange" /> <OverviewReportFilters @filter-change="onFilterChange" />
<div <div
class="flex-1 overflow-auto px-2 py-2 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2" class="flex-1 overflow-auto px-2 py-2 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
> >

View File

@ -1,31 +1,12 @@
<script> <script>
import V4Button from 'dashboard/components-next/button/Button.vue'; import V4Button from 'dashboard/components-next/button/Button.vue';
import { useAlert, useTrack } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import ReportFilters from './ReportFilters.vue'; import ReportFilters from './ReportFilters.vue';
import ReportContainer from '../ReportContainer.vue'; import ReportContainer from '../ReportContainer.vue';
import { GROUP_BY_FILTER } from '../constants'; import { GROUP_BY_FILTER } from '../constants';
import { generateFileName } from '../../../../../helper/downloadHelper'; import { generateFileName } from '../../../../../helper/downloadHelper';
import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
import ReportHeader from './ReportHeader.vue'; import ReportHeader from './ReportHeader.vue';
const GROUP_BY_OPTIONS = {
DAY: [{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' }],
WEEK: [
{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' },
{ id: 2, groupByKey: 'REPORT.GROUPING_OPTIONS.WEEK' },
],
MONTH: [
{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' },
{ id: 2, groupByKey: 'REPORT.GROUPING_OPTIONS.WEEK' },
{ id: 3, groupByKey: 'REPORT.GROUPING_OPTIONS.MONTH' },
],
YEAR: [
{ id: 2, groupByKey: 'REPORT.GROUPING_OPTIONS.WEEK' },
{ id: 3, groupByKey: 'REPORT.GROUPING_OPTIONS.MONTH' },
{ id: 4, groupByKey: 'REPORT.GROUPING_OPTIONS.YEAR' },
],
};
export default { export default {
components: { components: {
ReportHeader, ReportHeader,
@ -69,12 +50,19 @@ export default {
to: 0, to: 0,
selectedFilter: this.selectedItem, selectedFilter: this.selectedItem,
groupBy: GROUP_BY_FILTER[1], groupBy: GROUP_BY_FILTER[1],
groupByfilterItemsList: GROUP_BY_OPTIONS.DAY.map(this.translateOptions),
selectedGroupByFilter: null,
businessHours: false, businessHours: false,
}; };
}, },
computed: { computed: {
filterType() {
const pluralMap = {
agent: 'agents',
team: 'teams',
inbox: 'inboxes',
label: 'labels',
};
return pluralMap[this.type] || this.type;
},
filterItemsList() { filterItemsList() {
return this.$store.getters[this.getterKey] || []; return this.$store.getters[this.getterKey] || [];
}, },
@ -145,69 +133,29 @@ export default {
this.$store.dispatch(dispatchMethods[type], params); this.$store.dispatch(dispatchMethods[type], params);
} }
}, },
onDateRangeChange({ from, to, groupBy }) { onFilterChange(payload) {
// do not track filter change on inital load const { from, to, businessHours, groupBy } = payload;
if (this.from !== 0 && this.to !== 0) {
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
filterType: 'date',
reportType: this.type,
});
}
this.from = from; this.from = from;
this.to = to; this.to = to;
this.groupByfilterItemsList = this.fetchFilterItems(groupBy); this.businessHours = businessHours;
const filterItems = this.groupByfilterItemsList.filter(
item => item.id === this.groupBy.id if (groupBy) {
); this.groupBy = groupBy;
if (filterItems.length > 0) {
this.selectedGroupByFilter = filterItems[0];
} else { } else {
this.selectedGroupByFilter = this.groupByfilterItemsList[0]; this.groupBy = GROUP_BY_FILTER[1];
this.groupBy = GROUP_BY_FILTER[this.selectedGroupByFilter.id];
} }
this.fetchAllData();
},
onFilterChange(payload) {
if (payload) {
this.selectedFilter = payload;
this.fetchAllData();
}
},
onGroupByFilterChange(payload) {
this.groupBy = GROUP_BY_FILTER[payload.id];
this.fetchAllData();
useTrack(REPORTS_EVENTS.FILTER_REPORT, { // Get filter value directly from filterType key
filterType: 'groupBy', const filterValue = payload[this.filterType];
filterValue: this.groupBy?.period, if (filterValue) {
reportType: this.type, this.selectedFilter = Array.isArray(filterValue)
}); ? filterValue[0]
}, : filterValue;
fetchFilterItems(groupBy) { } else {
switch (groupBy) { this.selectedFilter = null;
case GROUP_BY_FILTER[2].period:
return GROUP_BY_OPTIONS.WEEK.map(this.translateOptions);
case GROUP_BY_FILTER[3].period:
return GROUP_BY_OPTIONS.MONTH.map(this.translateOptions);
case GROUP_BY_FILTER[4].period:
return GROUP_BY_OPTIONS.YEAR.map(this.translateOptions);
default:
return GROUP_BY_OPTIONS.DAY.map(this.translateOptions);
} }
},
translateOptions(opts) {
return { id: opts.id, groupBy: this.$t(opts.groupByKey) };
},
onBusinessHoursToggle(value) {
this.businessHours = value;
this.fetchAllData();
useTrack(REPORTS_EVENTS.FILTER_REPORT, { this.fetchAllData();
filterType: 'businessHours',
filterValue: value,
reportType: this.type,
});
}, },
}, },
}; };
@ -222,17 +170,12 @@ export default {
@click="downloadReports" @click="downloadReports"
/> />
</ReportHeader> </ReportHeader>
<ReportFilters <ReportFilters
v-if="filterItemsList" v-if="filterItemsList"
:type="type" :filter-type="filterType"
:filter-items-list="filterItemsList" :selected-item="selectedFilter"
:group-by-filter-items-list="groupByfilterItemsList"
:selected-group-by-filter="selectedGroupByFilter"
:current-filter="selectedFilter"
@date-range-change="onDateRangeChange"
@filter-change="onFilterChange" @filter-change="onFilterChange"
@group-by-filter-change="onGroupByFilterChange"
@business-hours-toggle="onBusinessHoursToggle"
/> />
<ReportContainer <ReportContainer
v-if="filterItemsList.length" v-if="filterItemsList.length"

View File

@ -1,57 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import ReportsFiltersAgents from '../../Filters/Agents.vue';
const mockStore = createStore({
modules: {
agents: {
namespaced: true,
state: {
agents: [],
},
getters: {
getAgents: state => state.agents,
},
actions: {
get: vi.fn(),
},
},
},
});
const mountParams = {
global: {
plugins: [mockStore],
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
},
};
describe('ReportsFiltersAgents.vue', () => {
it('emits "agents-filter-selection" event when handleInput is called', async () => {
const wrapper = shallowMount(ReportsFiltersAgents, mountParams);
const selectedAgents = [
{ id: 1, name: 'Agent 1' },
{ id: 2, name: 'Agent 2' },
];
await wrapper.setData({ selectedOptions: selectedAgents });
await wrapper.vm.handleInput();
expect(wrapper.emitted('agentsFilterSelection')).toBeTruthy();
expect(wrapper.emitted('agentsFilterSelection')[0]).toEqual([
selectedAgents,
]);
});
it('dispatches the "agents/get" action when the component is mounted', () => {
const dispatchSpy = vi.spyOn(mockStore, 'dispatch');
shallowMount(ReportsFiltersAgents, mountParams);
expect(dispatchSpy).toHaveBeenCalledWith('agents/get');
});
});

View File

@ -1,47 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import ReportsFiltersDateGroupBy from '../../Filters/DateGroupBy.vue';
import { GROUP_BY_OPTIONS } from '../../../constants';
const mountParams = {
global: {
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
},
};
describe('ReportsFiltersDateGroupBy.vue', () => {
it('emits "on-grouping-change" event when changeFilterSelection is called', () => {
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
const selectedFilter = GROUP_BY_OPTIONS.DAY;
wrapper.vm.changeFilterSelection(selectedFilter);
expect(wrapper.emitted('onGroupingChange')).toBeTruthy();
expect(wrapper.emitted('onGroupingChange')[0]).toEqual([selectedFilter]);
});
it('updates currentSelectedFilter when selectedOption is changed', async () => {
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
const newSelectedOption = GROUP_BY_OPTIONS.MONTH;
await wrapper.setProps({ selectedOption: newSelectedOption });
expect(wrapper.vm.currentSelectedFilter).toEqual({
...newSelectedOption,
groupBy: newSelectedOption.translationKey,
});
});
it('initializes translatedOptions correctly', () => {
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
const expectedOptions = wrapper.vm.validGroupOptions.map(option => ({
...option,
groupBy: option.translationKey,
}));
expect(wrapper.vm.translatedOptions).toEqual(expectedOptions);
});
});

View File

@ -1,41 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import ReportFiltersDateRange from '../../Filters/DateRange.vue';
import { DATE_RANGE_OPTIONS } from '../../../constants';
const mountParams = {
global: {
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
},
};
describe('ReportFiltersDateRange.vue', () => {
it('emits "onRangeChange" event when updateRange is called', () => {
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
const selectedRange = DATE_RANGE_OPTIONS.LAST_7_DAYS;
wrapper.vm.updateRange(selectedRange);
expect(wrapper.emitted('onRangeChange')).toBeTruthy();
expect(wrapper.emitted('onRangeChange')[0]).toEqual([selectedRange]);
});
it('initializes options correctly', () => {
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
const expectedIds = Object.values(DATE_RANGE_OPTIONS).map(
option => option.id
);
const receivedIds = wrapper.vm.options.map(option => option.id);
expect(receivedIds).toEqual(expectedIds);
});
it('initializes selectedOption correctly', () => {
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
const expectedId = Object.values(DATE_RANGE_OPTIONS)[0].id;
expect(wrapper.vm.selectedOption.id).toBe(expectedId);
});
});

View File

@ -1,65 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import ReportsFiltersInboxes from '../../Filters/Inboxes.vue';
const mountParams = {
global: {
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
},
};
describe('ReportsFiltersInboxes.vue', () => {
let store;
let inboxesModule;
beforeEach(() => {
inboxesModule = {
namespaced: true,
getters: {
getInboxes: () => () => [
{ id: 1, name: 'Inbox 1' },
{ id: 2, name: 'Inbox 2' },
],
},
actions: {
get: vi.fn(),
},
};
store = createStore({
modules: {
inboxes: inboxesModule,
},
});
});
it('dispatches "inboxes/get" action when component is mounted', () => {
shallowMount(ReportsFiltersInboxes, {
global: {
plugins: [store],
...mountParams.global,
},
});
expect(inboxesModule.actions.get).toHaveBeenCalled();
});
it('emits "inbox-filter-selection" event when handleInput is called', async () => {
const wrapper = shallowMount(ReportsFiltersInboxes, {
global: {
plugins: [store],
...mountParams.global,
},
});
const selectedInbox = { id: 1, name: 'Inbox 1' };
await wrapper.setData({ selectedOption: selectedInbox });
await wrapper.vm.handleInput();
expect(wrapper.emitted('inboxFilterSelection')).toBeTruthy();
expect(wrapper.emitted('inboxFilterSelection')[0]).toEqual([selectedInbox]);
});
});

View File

@ -1,67 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import ReportsFiltersLabels from '../../Filters/Labels.vue';
const mountParams = {
global: {
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
},
};
describe('ReportsFiltersLabels.vue', () => {
let store;
let labelsModule;
beforeEach(() => {
labelsModule = {
namespaced: true,
getters: {
getLabels: () => () => [
{ id: 1, title: 'Label 1', color: 'red' },
{ id: 2, title: 'Label 2', color: 'blue' },
],
},
actions: {
get: vi.fn(),
},
};
store = createStore({
modules: {
labels: labelsModule,
},
});
});
it('dispatches "labels/get" action when component is mounted', () => {
shallowMount(ReportsFiltersLabels, {
global: {
plugins: [store],
...mountParams.global,
},
});
expect(labelsModule.actions.get).toHaveBeenCalled();
});
it('emits "labels-filter-selection" event when handleInput is called', async () => {
const wrapper = shallowMount(ReportsFiltersLabels, {
global: {
plugins: [store],
...mountParams.global,
},
});
const selectedLabel = { id: 1, title: 'Label 1', color: 'red' };
await wrapper.setData({ selectedOption: selectedLabel });
await wrapper.vm.handleInput();
expect(wrapper.emitted('labelsFilterSelection')).toBeTruthy();
expect(wrapper.emitted('labelsFilterSelection')[0]).toEqual([
selectedLabel,
]);
});
});

View File

@ -1,43 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import ReportFiltersRatings from '../../Filters/Ratings.vue';
import { CSAT_RATINGS } from 'shared/constants/messages';
const mountParams = {
global: {
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
},
};
describe('ReportFiltersRatings.vue', () => {
it('emits "rating-filter-selection" event when handleInput is called', async () => {
const wrapper = shallowMount(ReportFiltersRatings, {
...mountParams,
});
const selectedRating = { value: 1, label: 'Rating 1' };
await wrapper.setData({ selectedOption: selectedRating });
await wrapper.vm.handleInput(selectedRating);
expect(wrapper.emitted('ratingFilterSelection')).toBeTruthy();
expect(wrapper.emitted('ratingFilterSelection')[0]).toEqual([
selectedRating,
]);
});
it('initializes options correctly', () => {
const wrapper = shallowMount(ReportFiltersRatings, {
...mountParams,
});
const expectedOptions = CSAT_RATINGS.map(option => ({
...option,
label: option.translationKey,
}));
expect(wrapper.vm.options).toEqual(expectedOptions);
});
});

View File

@ -1,65 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import ReportsFiltersTeams from '../../Filters/Teams.vue';
const mountParams = {
global: {
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
},
};
describe('ReportsFiltersTeams.vue', () => {
let store;
let teamsModule;
beforeEach(() => {
teamsModule = {
namespaced: true,
getters: {
getTeams: () => () => [
{ id: 1, name: 'Team 1' },
{ id: 2, name: 'Team 2' },
],
},
actions: {
get: vi.fn(),
},
};
store = createStore({
modules: {
teams: teamsModule,
},
});
});
it('dispatches "teams/get" action when component is mounted', () => {
shallowMount(ReportsFiltersTeams, {
global: {
plugins: [store],
...mountParams,
},
});
expect(teamsModule.actions.get).toHaveBeenCalled();
});
it('emits "team-filter-selection" event when handleInput is called', async () => {
const wrapper = shallowMount(ReportsFiltersTeams, {
global: {
plugins: [store],
...mountParams,
},
});
await wrapper.setData({ selectedOption: { id: 1, name: 'Team 1' } });
await wrapper.vm.handleInput();
expect(wrapper.emitted('teamFilterSelection')).toBeTruthy();
expect(wrapper.emitted('teamFilterSelection')[0]).toEqual([
{ id: 1, name: 'Team 1' },
]);
});
});

View File

@ -0,0 +1,69 @@
export const generateReportURLParams = ({
from,
to,
businessHours,
groupBy,
range,
}) => {
const params = {};
// Always include from/to dates
if (from) params.from = from;
if (to) params.to = to;
if (businessHours) params.business_hours = 'true';
if (groupBy) params.group_by = groupBy;
// Include range type (last7days, last3months, custom, etc.)
if (range) params.range = range;
return params;
};
export const parseReportURLParams = query => {
const { from, to, business_hours, group_by, range } = query;
return {
from: from ? Number(from) : null,
to: to ? Number(to) : null,
businessHours: business_hours === 'true',
groupBy: group_by ? Number(group_by) : null,
range: range || null,
};
};
// Parse filter params from URL (agent_id, inbox_id, team_id, sla_policy_id, label, rating)
export const parseFilterURLParams = query => {
return {
agent_id: query.agent_id ? Number(query.agent_id) : null,
inbox_id: query.inbox_id ? Number(query.inbox_id) : null,
team_id: query.team_id ? Number(query.team_id) : null,
sla_policy_id: query.sla_policy_id ? Number(query.sla_policy_id) : null,
label: query.label || null,
rating: query.rating ? Number(query.rating) : null,
};
};
// Generate filter URL params (only include non-null values)
export const generateFilterURLParams = filters => {
const params = {};
if (filters.agent_id) params.agent_id = filters.agent_id;
if (filters.inbox_id) params.inbox_id = filters.inbox_id;
if (filters.team_id) params.team_id = filters.team_id;
if (filters.sla_policy_id) params.sla_policy_id = filters.sla_policy_id;
if (filters.label) params.label = filters.label;
if (filters.rating) params.rating = filters.rating;
return params;
};
// Merge date range and filter params for complete URL
export const generateCompleteURLParams = ({
from,
to,
range,
filters = {},
}) => {
const dateParams = generateReportURLParams({ from, to, range });
const filterParams = generateFilterURLParams(filters);
return { ...dateParams, ...filterParams };
};

View File

@ -0,0 +1,350 @@
import {
generateReportURLParams,
parseReportURLParams,
parseFilterURLParams,
generateFilterURLParams,
generateCompleteURLParams,
} from './reportFilterHelper';
describe('reportFilterHelper', () => {
describe('generateReportURLParams', () => {
it('generates URL params with from and to dates', () => {
const params = generateReportURLParams({
from: 1738607400,
to: 1770229799,
});
expect(params).toEqual({
from: 1738607400,
to: 1770229799,
});
});
it('includes business hours when true', () => {
const params = generateReportURLParams({
from: 1738607400,
to: 1770229799,
businessHours: true,
});
expect(params).toEqual({
from: 1738607400,
to: 1770229799,
business_hours: 'true',
});
});
it('excludes business hours when false', () => {
const params = generateReportURLParams({
from: 1738607400,
to: 1770229799,
businessHours: false,
});
expect(params).toEqual({
from: 1738607400,
to: 1770229799,
});
});
it('includes group by parameter', () => {
const params = generateReportURLParams({
from: 1738607400,
to: 1770229799,
groupBy: 3,
});
expect(params).toEqual({
from: 1738607400,
to: 1770229799,
group_by: 3,
});
});
it('includes range type', () => {
const params = generateReportURLParams({
from: 1738607400,
to: 1770229799,
range: 'last7days',
});
expect(params).toEqual({
from: 1738607400,
to: 1770229799,
range: 'last7days',
});
});
it('generates complete URL params with all options', () => {
const params = generateReportURLParams({
from: 1738607400,
to: 1770229799,
businessHours: true,
groupBy: 3,
range: 'lastYear',
});
expect(params).toEqual({
from: 1738607400,
to: 1770229799,
business_hours: 'true',
group_by: 3,
range: 'lastYear',
});
});
});
describe('parseReportURLParams', () => {
it('parses from and to dates as numbers', () => {
const result = parseReportURLParams({
from: '1738607400',
to: '1770229799',
});
expect(result).toEqual({
from: 1738607400,
to: 1770229799,
businessHours: false,
groupBy: null,
range: null,
});
});
it('parses business hours as boolean', () => {
const result = parseReportURLParams({
from: '1738607400',
to: '1770229799',
business_hours: 'true',
});
expect(result.businessHours).toBe(true);
});
it('returns false for business hours when not "true"', () => {
const result = parseReportURLParams({
from: '1738607400',
to: '1770229799',
business_hours: 'false',
});
expect(result.businessHours).toBe(false);
});
it('parses group by as number', () => {
const result = parseReportURLParams({
from: '1738607400',
to: '1770229799',
group_by: '3',
});
expect(result.groupBy).toBe(3);
});
it('parses range type', () => {
const result = parseReportURLParams({
from: '1738607400',
to: '1770229799',
range: 'last7days',
});
expect(result.range).toBe('last7days');
});
it('returns null for missing parameters', () => {
const result = parseReportURLParams({});
expect(result).toEqual({
from: null,
to: null,
businessHours: false,
groupBy: null,
range: null,
});
});
it('parses complete URL params with all options', () => {
const result = parseReportURLParams({
from: '1738607400',
to: '1770229799',
business_hours: 'true',
group_by: '3',
range: 'lastYear',
});
expect(result).toEqual({
from: 1738607400,
to: 1770229799,
businessHours: true,
groupBy: 3,
range: 'lastYear',
});
});
it('handles numeric values correctly', () => {
const result = parseReportURLParams({
from: 1738607400,
to: 1770229799,
group_by: 3,
});
expect(result.from).toBe(1738607400);
expect(result.to).toBe(1770229799);
expect(result.groupBy).toBe(3);
});
});
describe('round-trip conversion', () => {
it('maintains data integrity through generate and parse cycle', () => {
const original = {
from: 1738607400,
to: 1770229799,
businessHours: true,
groupBy: 3,
range: 'lastYear',
};
const urlParams = generateReportURLParams(original);
const parsed = parseReportURLParams(urlParams);
expect(parsed.from).toBe(original.from);
expect(parsed.to).toBe(original.to);
expect(parsed.businessHours).toBe(original.businessHours);
expect(parsed.groupBy).toBe(original.groupBy);
expect(parsed.range).toBe(original.range);
});
});
describe('parseFilterURLParams', () => {
it('parses all filter params as numbers', () => {
const result = parseFilterURLParams({
agent_id: '123',
inbox_id: '456',
team_id: '789',
sla_policy_id: '101',
rating: '4',
});
expect(result).toEqual({
agent_id: 123,
inbox_id: 456,
team_id: 789,
sla_policy_id: 101,
label: null,
rating: 4,
});
});
it('parses label as string', () => {
const result = parseFilterURLParams({
label: 'bug',
});
expect(result.label).toBe('bug');
});
it('returns null for missing parameters', () => {
const result = parseFilterURLParams({});
expect(result).toEqual({
agent_id: null,
inbox_id: null,
team_id: null,
sla_policy_id: null,
label: null,
rating: null,
});
});
});
describe('generateFilterURLParams', () => {
it('includes only non-null filter values', () => {
const params = generateFilterURLParams({
agent_id: 123,
inbox_id: null,
team_id: 456,
rating: null,
});
expect(params).toEqual({
agent_id: 123,
team_id: 456,
});
});
it('includes label when present', () => {
const params = generateFilterURLParams({
label: 'bug',
});
expect(params).toEqual({
label: 'bug',
});
});
it('returns empty object when all values are null', () => {
const params = generateFilterURLParams({
agent_id: null,
inbox_id: null,
team_id: null,
});
expect(params).toEqual({});
});
});
describe('generateCompleteURLParams', () => {
it('merges date and filter params', () => {
const params = generateCompleteURLParams({
from: 1738607400,
to: 1770229799,
range: 'last7days',
filters: {
agent_id: 123,
inbox_id: 456,
},
});
expect(params).toEqual({
from: 1738607400,
to: 1770229799,
range: 'last7days',
agent_id: 123,
inbox_id: 456,
});
});
it('handles empty filters', () => {
const params = generateCompleteURLParams({
from: 1738607400,
to: 1770229799,
range: 'last7days',
filters: {},
});
expect(params).toEqual({
from: 1738607400,
to: 1770229799,
range: 'last7days',
});
});
it('excludes null filter values', () => {
const params = generateCompleteURLParams({
from: 1738607400,
to: 1770229799,
filters: {
agent_id: 123,
inbox_id: null,
team_id: 456,
},
});
expect(params).toEqual({
from: 1738607400,
to: 1770229799,
agent_id: 123,
team_id: 456,
});
});
});
});

View File

@ -27,7 +27,7 @@ export const getters = {
.sort((a, b) => a.title.localeCompare(b.title)); .sort((a, b) => a.title.localeCompare(b.title));
}, },
getLabelById: _state => id => { getLabelById: _state => id => {
return _state.records.find(record => record.id === Number(id)); return _state.records.find(record => record.id === Number(id)) || {};
}, },
}; };