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
41 changed files with 1678 additions and 1737 deletions

View File

@@ -1,11 +1,14 @@
<script setup>
import { ref, watch } from 'vue';
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
getActiveDateRange,
moveCalendarDate,
DATE_RANGE_TYPES,
CALENDAR_TYPES,
CALENDAR_PERIODS,
isNavigableRange,
getRangeAtOffset,
} from './helpers/DatePickerHelper';
import {
isValid,
@@ -13,14 +16,14 @@ import {
subDays,
startOfDay,
endOfDay,
isBefore,
subMonths,
addMonths,
isSameMonth,
differenceInCalendarMonths,
differenceInCalendarWeeks,
setMonth,
setYear,
isAfter,
getWeek,
} from 'date-fns';
import { useAlert } from 'dashboard/composables';
import DatePickerButton from './components/DatePickerButton.vue';
@@ -32,98 +35,187 @@ import CalendarWeek from './components/CalendarWeek.vue';
import CalendarFooter from './components/CalendarFooter.vue';
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 { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
const showDatePicker = ref(false);
const calendarViews = ref({ start: WEEK, end: WEEK });
const currentDate = ref(new Date());
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
const selectedEndDate = ref(endOfDay(currentDate.value));
// Setting the start and end calendar
const startCurrentDate = ref(startOfDay(selectedStartDate.value));
// Use dates from v-model if provided, otherwise default to last 7 days
const selectedStartDate = ref(
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(
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(selectedEndDate.value) // Always shows the month of the end date starting from the first (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) // Different months: show end month on right (e.g., Dec 5 - Jan 3 shows Dec + Jan)
);
const selectingEndDate = ref(false);
const selectedRange = ref(LAST_7_DAYS);
const selectedRange = ref(rangeType.value || LAST_7_DAYS);
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 manualEndDate = ref(selectedEndDate.value);
// Watcher will set the start and end dates based on the selected range
watch(selectedRange, newRange => {
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
// Watcher 1: Sync v-model props from parent component
// Handles: URL params, parent component updates, rangeType changes
watch(
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
if (isValid(newStart)) {
manualStartDate.value = newStart;
} else {
manualStartDate.value = selectedStartDate.value;
[rangeType, dateRange],
([newRangeType, newDateRange]) => {
if (newRangeType && newRangeType !== selectedRange.value) {
selectedRange.value = newRangeType;
monthOffset.value = 0;
// 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)) {
manualEndDate.value = newEnd;
} else {
manualEndDate.value = selectedEndDate.value;
// When parent provides new dateRange (e.g., from URL params)
// Skip if navigating with arrows — offset controls dates in that case
if (newDateRange?.[0] && newDateRange?.[1] && monthOffset.value === 0) {
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 }
);
// Watcher to ensure dates are always in logical order
// This watch is will ensure that the start date is always before the end date
// Watcher 2: Keep manual input fields in sync with selected dates
// Updates the input field values when dates change programmatically
watch(
[startCurrentDate, endCurrentDate],
([newStart, newEnd], [oldStart, oldEnd]) => {
const monthDifference = differenceInCalendarMonths(newEnd, newStart);
if (newStart !== oldStart) {
if (isAfter(newStart, newEnd) || monthDifference === 0) {
// 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);
}
}
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
manualStartDate.value = isValid(newStart)
? newStart
: selectedStartDate.value;
manualEndDate.value = isValid(newEnd) ? newEnd : selectedEndDate.value;
},
{ immediate: true, deep: true }
{ immediate: true }
);
const setDateRange = range => {
selectedRange.value = range.value;
monthOffset.value = 0;
const { start, end } = getActiveDateRange(range.value, currentDate.value);
selectedStartDate.value = start;
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) => {
@@ -134,12 +226,27 @@ const moveCalendar = (calendar, direction, period = MONTH) => {
direction,
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 => {
selectedRange.value = CUSTOM_RANGE;
monthOffset.value = 0;
if (!selectingEndDate.value || day < selectedStartDate.value) {
selectedStartDate.value = day;
selectedEndDate.value = null;
@@ -175,10 +282,10 @@ const openCalendar = (index, calendarType, period = MONTH) => {
const updateManualInput = (newDate, calendarType) => {
if (calendarType === START_CALENDAR) {
selectedStartDate.value = newDate;
startCurrentDate.value = newDate;
startCurrentDate.value = startOfMonth(newDate);
} else {
selectedEndDate.value = newDate;
endCurrentDate.value = newDate;
endCurrentDate.value = startOfMonth(newDate);
}
selectingEndDate.value = false;
};
@@ -188,13 +295,22 @@ const handleManualInputError = message => {
};
const resetDatePicker = () => {
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6));
selectedEndDate.value = endOfDay(currentDate.value);
// Calculate Last 7 days from today
const startDate = startOfDay(subDays(currentDate.value, 6));
const endDate = 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;
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 };
};
@@ -203,26 +319,58 @@ const emitDateRange = () => {
useAlert('Please select a valid time range');
} else {
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 = () => {
showDatePicker.value = false;
if (isValid(selectedStartDate.value) && isValid(selectedEndDate.value)) {
emitDateRange();
} else {
showDatePicker.value = false;
}
};
</script>
<template>
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
<div class="relative flex-shrink-0 font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selected-range="selectedRange"
@open="showDatePicker = !showDatePicker"
:show-month-navigation="showMonthNavigation"
:can-navigate-next="canNavigateNext"
:navigation-label="navigationLabel"
@open="toggleDatePicker"
@navigate-month="navigateMonth"
/>
<div
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
:selected-range="selectedRange"

View File

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

View File

@@ -18,24 +18,25 @@ const setDateRange = range => {
<template>
<div class="w-[200px] flex flex-col items-start">
<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') }}
</h4>
<div class="flex flex-col items-start w-full">
<button
v-for="range in dateRanges"
:key="range.label"
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="
range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
<template v-for="range in dateRanges" :key="range.label">
<div v-if="range.separator" class="w-full border-t border-n-strong" />
<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="
range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
</template>
</div>
</div>
</template>

View File

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

View File

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

View File

@@ -77,7 +77,7 @@ const selectYear = year => {
'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,
}"
@click="selectYear(year)"
@click.stop="selectYear(year)"
>
{{ year }}
</button>

View File

@@ -2,6 +2,8 @@
import { computed } from 'vue';
import { dateRanges } from '../helpers/DatePickerHelper';
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({
selectedStartDate: Date,
@@ -10,9 +12,21 @@ const props = defineProps({
type: String,
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 startDate = props.selectedStartDate;
@@ -22,19 +36,15 @@ const formatDateRange = computed(() => {
return 'Select a date range';
}
const formatString = isSameYear(startDate, endDate)
? 'MMM d' // Same year: "Apr 1"
: 'MMM d yyyy'; // Different years: "Apr 1 2025"
const crossesYears = !isSameYear(startDate, endDate);
if (isSameYear(startDate, new Date()) && isSameYear(endDate, new Date())) {
// Both dates are in the current year
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
// Always show years when crossing year boundaries
if (crossesYears) {
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(
endDate,
formatString
)}`;
// For same year, always show the year for clarity
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`;
});
const activeDateRange = computed(
@@ -47,17 +57,46 @@ const openDatePicker = () => {
</script>
<template>
<button
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"
@click="openDatePicker"
>
<fluent-icon class="text-n-slate-12" icon="calendar" size="16" />
<span class="text-sm font-medium text-n-slate-12">
{{ $t(activeDateRange) }}
</span>
<span class="text-sm font-medium text-n-slate-11">
{{ formatDateRange }}
</span>
<fluent-icon class="text-n-slate-12" icon="chevron-down" size="14" />
</button>
<div class="inline-flex items-center gap-1">
<button
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"
>
<Icon
icon="i-lucide-calendar-range"
class="text-n-slate-11 size-3.5 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12 truncate">
{{ navigationLabel || $t(activeDateRange) }}
</span>
<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>

View File

@@ -10,6 +10,8 @@ import {
isSameMonth,
format,
startOfWeek,
endOfWeek,
addWeeks,
addDays,
eachDayOfInterval,
endOfMonth,
@@ -34,13 +36,27 @@ export const dateRanges = [
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
value: 'last3months',
separator: true,
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
value: 'last6months',
},
{ 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 = {
@@ -49,6 +65,8 @@ export const DATE_RANGE_TYPES = {
LAST_3_MONTHS: 'last3months',
LAST_6_MONTHS: 'last6months',
LAST_YEAR: 'lastYear',
THIS_WEEK: 'thisWeek',
MONTH_TO_DATE: 'monthToDate',
CUSTOM_RANGE: 'custom',
};
@@ -210,6 +228,14 @@ export const getActiveDateRange = (range, currentDate) => {
start: startOfDay(subMonths(currentDate, 12)),
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 }),
};
@@ -217,3 +243,48 @@ export const getActiveDateRange = (range, 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
v-model="value"
:placeholder="inputPlaceholder"
type="text"
class="w-full mb-0 text-sm !outline-0 bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
type="search"
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>
<!-- Clear filter button -->

View File

@@ -1,5 +1,8 @@
{
"DATE_PICKER": {
"PREVIOUS_PERIOD": "Previous period",
"NEXT_PERIOD": "Next period",
"WEEK_NUMBER": "Week #{weekNumber}",
"APPLY_BUTTON": "Apply",
"CLEAR_BUTTON": "Clear",
"DATE_RANGE_INPUT": {
@@ -13,6 +16,8 @@
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
"LAST_YEAR": "Last year",
"THIS_WEEK": "This week",
"MONTH_TO_DATE": "This month",
"CUSTOM_RANGE": "Custom date range"
}
}

View File

@@ -128,11 +128,16 @@
},
"AGENT_REPORTS": {
"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...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
"FILTER_DROPDOWN_LABEL": "Select Agent",
"FILTERS": {
"INPUT_PLACEHOLDER": {
"AGENTS": "Search agents"
}
},
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
@@ -201,6 +206,11 @@
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
"FILTER_DROPDOWN_LABEL": "Select Label",
"FILTERS": {
"INPUT_PLACEHOLDER": {
"LABELS": "Search labels"
}
},
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
@@ -271,6 +281,11 @@
"FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"FILTERS": {
"INPUT_PLACEHOLDER": {
"INBOXES": "Search inboxes"
}
},
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
@@ -334,11 +349,19 @@
},
"TEAM_REPORTS": {
"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...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
"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": {
"CONVERSATIONS": {
"NAME": "Conversations",

View File

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

View File

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

View File

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

View File

@@ -145,7 +145,7 @@ export default {
<template>
<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
v-for="metric in metrics"

View File

@@ -2,6 +2,8 @@
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { subDays, fromUnixTime } from 'date-fns';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import {
buildFilterList,
@@ -13,6 +15,12 @@ import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
import AddFilterChip from '../Filters/v3/AddFilterChip.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({
showTeamFilter: {
@@ -25,16 +33,29 @@ const emit = defineEmits(['filterChange']);
const { t } = useI18n();
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 showSubDropdownMenu = ref(false);
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({
user_ids: null,
inbox_id: null,
team_id: null,
rating: null,
user_ids: urlFilters.agent_id,
inbox_id: urlFilters.inbox_id,
team_id: urlFilters.team_id,
rating: urlFilters.rating,
});
const agents = computed(() => store.getters['agents/getAgents']);
@@ -111,13 +132,17 @@ const activeFilters = computed(() => {
const activeKeys = Object.keys(appliedFilters.value).filter(
key => appliedFilters.value[key]
);
return activeKeys.map(key => {
const filterType = getFilterType(key, 'keyToType');
const items = getFilterSource(filterType);
const item = getActiveFilter(items, filterType, appliedFilters.value[key]);
const displayName =
item?.name || item?.title || `ID: ${appliedFilters.value[key]}`;
return {
id: item?.id,
name: item?.name || '',
id: item?.id || appliedFilters.value[key],
name: displayName,
type: filterType,
options: getFilterOptions(filterType),
};
@@ -130,7 +155,23 @@ const hasActiveFilters = computed(() =>
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 = () => {
updateURLParams();
emit('filterChange', {
from: from.value,
to: to.value,
@@ -200,7 +241,9 @@ const openActiveFilterDropdown = filterType => {
};
const onDateRangeChange = value => {
customDateRange.value = value;
const [startDate, endDate, rangeType] = value;
customDateRange.value = [startDate, endDate];
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
emitChange();
};
@@ -211,7 +254,11 @@ onMounted(() => {
<template>
<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
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,
},
id: {
type: Number,
required: true,
type: [Number, null],
default: null,
},
type: {
type: String,
@@ -35,6 +35,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
showClearFilter: {
type: Boolean,
default: true,
},
});
const emit = defineEmits([
@@ -60,7 +64,7 @@ const closeDropdown = () => emit('closeDropdown');
<FilterListDropdown
v-if="options"
v-on-clickaway="closeDropdown"
show-clear-filter
:show-clear-filter="showClearFilter"
:list-items="options"
:active-filter-id="id"
: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>
import endOfDay from 'date-fns/endOfDay';
import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay';
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import subDays from 'date-fns/subDays';
import Avatar from 'next/avatar/Avatar.vue';
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
import differenceInDays from 'date-fns/differenceInDays';
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 { 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 {
components: {
WootDateRangePicker,
Avatar,
ToggleSwitch,
const props = defineProps({
filterType: {
type: String,
required: false,
default: '',
validator: value =>
['teams', 'inboxes', 'labels', 'agents', ''].includes(value),
},
props: {
currentFilter: {
type: Object,
default: () => null,
},
filterItemsList: {
type: Array,
default: () => [],
},
groupByFilterItemsList: {
type: Array,
default: () => [],
},
type: {
type: String,
default: 'agent',
},
selectedGroupByFilter: {
type: Object,
default: () => {},
},
selectedItem: {
type: Object,
default: null,
},
emits: [
'businessHoursToggle',
'dateRangeChange',
'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,
};
showGroupBy: {
type: Boolean,
default: true,
},
computed: {
dateRange() {
return [
{ 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;
},
showBusinessHours: {
type: Boolean,
default: true,
},
watch: {
filterItemsList(val) {
this.currentSelectedFilter = !this.currentFilter
? 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);
},
showEntityFilter: {
type: Boolean,
default: true,
},
});
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>
<template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-y-0.5 gap-x-2">
<div v-if="type === 'agent'" class="multiselect-wrap--small">
<p class="mb-2 text-xs font-medium">
{{ $t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL') }}
</p>
<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 class="flex flex-col w-full gap-3 lg:flex-row">
<WootDatePicker
v-model:date-range="customDateRange"
v-model:range-type="selectedDateRange"
@date-range-changed="onDateRangeChange"
/>
<div v-else-if="type === 'label'" class="multiselect-wrap--small">
<p class="mb-2 text-xs font-medium">
{{ $t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedFilter"
:placeholder="multiselectLabel"
label="title"
track-by="id"
:options="filterItemsList"
:option-height="24"
:show-labels="false"
@update:model-value="changeFilterSelection"
>
<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 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 class="flex gap-2 items-center w-full">
<ActiveFilterChip
v-if="showEntityFilter"
:id="appliedFilters[getFilterKey()]"
:name="selectedFilterName"
:type="filterType"
:options="filterOptions"
:active-filter-type="activeFilterType"
:show-menu="showSubDropdownMenu"
:placeholder="filterPlaceholder"
:show-clear-filter="false"
enable-search
@toggle-dropdown="openActiveFilterDropdown"
@close-dropdown="closeActiveFilterDropdown"
@add-filter="addFilter"
/>
</div>
<div class="multiselect-wrap--small">
<p class="mb-2 text-xs font-medium">
{{ $t('REPORT.DURATION_FILTER_LABEL') }}
</p>
<multiselect
v-model="currentDateRangeSelection"
track-by="name"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
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"
<ActiveFilterChip
v-if="isGroupByPossible"
:id="groupBy?.id"
:name="
groupByfilterItemsList.find(item => item.id === groupBy?.id)?.name ||
$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')
"
type="groupBy"
:options="groupByfilterItemsList"
:active-filter-type="showGroupByDropdown ? 'groupBy' : ''"
:show-menu="showGroupByDropdown"
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
:options="groupByFilterItemsList"
:allow-empty="false"
:show-labels="false"
@update:model-value="changeGroupByFilterSelection"
:enable-search="false"
:show-clear-filter="false"
@toggle-dropdown="toggleGroupByDropdown"
@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>
</template>

View File

@@ -1,155 +1,201 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import {
buildFilterList,
getActiveFilter,
getFilterType,
} from './helpers/SLAFilterHelpers';
import {
parseFilterURLParams,
generateCompleteURLParams,
} from '../../helpers/reportFilterHelper';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
export default {
components: {
FilterButton,
ActiveFilterChip,
AddFilterChip,
},
emits: ['filterChange'],
data() {
const emit = defineEmits(['filterChange']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
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 {
showDropdownMenu: false,
showSubDropdownMenu: false,
activeFilterType: '',
appliedFilters: {
assigned_agent_id: null,
inbox_id: null,
team_id: null,
sla_policy_id: null,
label_list: null,
},
id: item.id,
name: filterType === 'labels' ? item.title : item.name,
type: filterType,
options: buildFilterList(sources[filterType], filterType),
};
},
computed: {
...mapGetters({
agents: 'agents/getAgents',
inboxes: 'inboxes/getInboxes',
teams: 'teams/getTeams',
labels: 'labels/getLabels',
sla: 'sla/getSLA',
}),
filterListMenuItems() {
const filterTypes = [
{ id: '1', name: this.$t('SLA_REPORTS.DROPDOWN.SLA'), type: 'sla' },
{
id: '2',
name: this.$t('SLA_REPORTS.DROPDOWN.INBOXES'),
type: 'inboxes',
},
{
id: '3',
name: this.$t('SLA_REPORTS.DROPDOWN.AGENTS'),
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),
}));
});
});
const hasActiveFilters = computed(() =>
Object.values(appliedFilters.value).some(value => value !== null)
);
const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
const updateURLParams = () => {
const params = generateCompleteURLParams({
from: route.query.from,
to: route.query.to,
range: route.query.range,
filters: {
agent_id: appliedFilters.value.assigned_agent_id,
inbox_id: appliedFilters.value.inbox_id,
team_id: appliedFilters.value.team_id,
sla_policy_id: appliedFilters.value.sla_policy_id,
label: appliedFilters.value.label_list,
},
activeFilters() {
// Get the active filters from the applied filters
// 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();
},
},
});
router.replace({ query: params });
};
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>
<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 subDays from 'date-fns/subDays';
import { DATE_RANGE_OPTIONS } from '../../constants';
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
import { subDays, fromUnixTime } from 'date-fns';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import {
generateReportURLParams,
parseReportURLParams,
parseFilterURLParams,
} from '../../helpers/reportFilterHelper';
export default {
components: {
SLAFilter,
},
emits: ['filterChange'],
const emit = defineEmits(['filterChange']);
data() {
return {
selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
selectedGroupByFilter: null,
customDateRange: [new Date(), new Date()],
};
},
computed: {
to() {
return getUnixEndOfDay(this.customDateRange[1]);
},
from() {
return getUnixStartOfDay(this.customDateRange[0]);
},
},
watch: {
businessHoursSelected() {
this.emitChange();
},
},
mounted() {
this.setInitialRange();
},
methods: {
setInitialRange() {
const { offset } = this.selectedDateRange;
const fromDate = subDays(new Date(), offset);
const from = getUnixStartOfDay(fromDate);
const to = getUnixEndOfDay(new Date());
this.$emit('filterChange', {
from,
to,
...this.selectedGroupByFilter,
});
},
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 route = useRoute();
const router = useRouter();
// Initialize from URL params immediately
const urlParams = parseReportURLParams(route.query);
const initialDateRange =
urlParams.from && urlParams.to
? [fromUnixTime(urlParams.from), fromUnixTime(urlParams.to)]
: [subDays(new Date(), 6), new Date()];
const selectedDateRange = ref(urlParams.range || 'last7days');
const selectedGroupByFilter = ref(null);
const customDateRange = ref(initialDateRange);
const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
const updateURLParams = () => {
const dateParams = generateReportURLParams({
from: from.value,
to: to.value,
range: selectedDateRange.value,
});
const filterParams = parseFilterURLParams(route.query);
const params = { ...dateParams };
if (filterParams.agent_id) params.agent_id = filterParams.agent_id;
if (filterParams.inbox_id) params.inbox_id = filterParams.inbox_id;
if (filterParams.team_id) params.team_id = filterParams.team_id;
if (filterParams.sla_policy_id)
params.sla_policy_id = filterParams.sla_policy_id;
if (filterParams.label) params.label = filterParams.label;
router.replace({ query: params });
};
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>
<template>
<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" />
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup>
import ReportFilterSelector from './FilterSelector.vue';
import OverviewReportFilters from './OverviewReportFilters.vue';
import { formatTime } from '@chatwoot/utils';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import Table from 'dashboard/components/table/Table.vue';
@@ -178,7 +178,7 @@ defineExpose({ downloadReports });
</script>
<template>
<ReportFilterSelector @filter-change="onFilterChange" />
<OverviewReportFilters @filter-change="onFilterChange" />
<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"
>

View File

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