feat: Refactor reports filters (#13443)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,7 +23,6 @@ const onClickApply = () => {
|
||||
/>
|
||||
<NextButton
|
||||
sm
|
||||
ghost
|
||||
:label="$t('DATE_PICKER.APPLY_BUTTON')"
|
||||
@click="onClickApply"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 agent’s 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 team’s 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",
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
getAllData() {
|
||||
try {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)) || {};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user