feat: SLA report filter (#9218)

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Sivin Varghese
2024-04-12 11:03:18 +05:30
committed by GitHub
parent e8fe3c7c05
commit 3b6ae772bf
19 changed files with 580 additions and 67 deletions

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col flex-1 px-4 pt-4 overflow-auto">
<div class="flex flex-col flex-1 gap-6 px-4 pt-4 overflow-auto">
<SLAReportFilters @filter-change="onFilterChange" />
<woot-button
color-scheme="success"
@@ -44,8 +44,15 @@ export default {
data() {
return {
pageNumber: 1,
from: 0,
to: 0,
activeFilter: {
from: 0,
to: 0,
assigned_agent_id: null,
inbox_id: null,
team_id: null,
sla_policy_id: null,
label_list: null,
},
};
},
computed: {
@@ -57,6 +64,11 @@ export default {
}),
},
mounted() {
this.$store.dispatch('agents/get');
this.$store.dispatch('inboxes/get');
this.$store.dispatch('teams/get');
this.$store.dispatch('labels/get');
this.$store.dispatch('sla/get');
this.fetchSLAMetrics();
this.fetchSLAReports();
},
@@ -64,22 +76,17 @@ export default {
fetchSLAReports({ pageNumber } = {}) {
this.$store.dispatch('slaReports/get', {
page: pageNumber || this.pageNumber,
from: this.from,
to: this.to,
...this.activeFilter,
});
},
fetchSLAMetrics() {
this.$store.dispatch('slaReports/getMetrics', {
from: this.from,
to: this.to,
});
this.$store.dispatch('slaReports/getMetrics', this.activeFilter);
},
onPageChange(pageNumber) {
this.fetchSLAReports({ pageNumber });
},
onFilterChange({ from, to }) {
this.from = from;
this.to = to;
onFilterChange(params) {
this.activeFilter = params;
this.fetchSLAReports();
this.fetchSLAMetrics();
},

View File

@@ -0,0 +1,73 @@
<script setup>
import FilterButton from './FilterButton.vue';
import FilterListDropdown from './FilterListDropdown.vue';
const props = defineProps({
name: {
type: String,
required: true,
},
id: {
type: Number,
required: true,
},
type: {
type: String,
required: true,
},
options: {
type: Array,
default: () => [],
},
activeFilterType: {
type: String,
default: '',
},
showMenu: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
enableSearch: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'toggleDropdown',
'removeFilter',
'addFilter',
'closeDropdown',
]);
const toggleDropdown = () => emit('toggleDropdown', props.type);
const removeFilter = () => emit('removeFilter', props.type);
const addFilter = item => emit('addFilter', item);
const closeDropdown = () => emit('closeDropdown');
</script>
<template>
<filter-button
right-icon="chevron-down"
:button-text="name"
class="bg-slate-50 dark:bg-slate-800 hover:bg-slate-75 dark:hover:bg-slate-800"
@click="toggleDropdown"
>
<template v-if="showMenu && activeFilterType === type" #dropdown>
<filter-list-dropdown
v-if="options"
v-on-clickaway="closeDropdown"
:list-items="options"
:active-filter-id="id"
:input-placeholder="placeholder"
:enable-search="enableSearch"
class="flex flex-col w-[240px] overflow-y-auto left-0 md:left-auto md:right-0 top-10"
@click="addFilter"
@removeFilter="removeFilter"
/>
</template>
</filter-button>
</template>

View File

@@ -0,0 +1,98 @@
<script setup>
import FilterButton from './FilterButton.vue';
import FilterListDropdown from './FilterListDropdown.vue';
import FilterListItemButton from './FilterListItemButton.vue';
import FilterDropdownEmptyState from './FilterDropdownEmptyState.vue';
import { ref } from 'vue';
defineProps({
name: {
type: String,
required: true,
},
menuOption: {
type: Array,
default: () => [],
},
showMenu: {
type: Boolean,
default: false,
},
placeholderI18nKey: {
type: String,
default: '',
},
enableSearch: {
type: Boolean,
default: true,
},
emptyStateMessage: {
type: String,
default: '',
},
});
const hoveredItemId = ref(null);
const showSubMenu = id => {
hoveredItemId.value = id;
};
const hideSubMenu = () => {
hoveredItemId.value = null;
};
const isHovered = id => hoveredItemId.value === id;
const emit = defineEmits(['toggleDropdown', 'addFilter', 'closeDropdown']);
const toggleDropdown = () => emit('toggleDropdown');
const addFilter = item => {
emit('addFilter', item);
hideSubMenu();
};
const closeDropdown = () => {
hideSubMenu();
emit('closeDropdown');
};
</script>
<template>
<filter-button :button-text="name" left-icon="filter" @click="toggleDropdown">
<!-- Dropdown with search and sub-dropdown -->
<template v-if="showMenu" #dropdown>
<filter-list-dropdown
v-on-clickaway="closeDropdown"
class="left-0 md:right-0 top-10"
>
<template #listItem>
<filter-dropdown-empty-state
v-if="!menuOption.length"
:message="emptyStateMessage"
/>
<filter-list-item-button
v-for="item in menuOption"
:key="item.id"
:button-text="item.name"
@mouseenter="showSubMenu(item.id)"
@mouseleave="hideSubMenu"
@focus="showSubMenu(item.id)"
>
<!-- Submenu with search and clear button -->
<template v-if="item.options && isHovered(item.id)" #dropdown>
<filter-list-dropdown
:list-items="item.options"
:input-placeholder="
$t(`${placeholderI18nKey}.${item.type.toUpperCase()}`)
"
:enable-search="enableSearch"
class="flex flex-col w-[216px] overflow-y-auto top-0 left-36"
@click="addFilter"
/>
</template>
</filter-list-item-button>
</template>
</filter-list-dropdown>
</template>
</filter-button>
</template>

View File

@@ -4,23 +4,44 @@ defineProps({
type: String,
default: '',
},
rightIcon: {
type: String,
default: '',
},
leftIcon: {
type: String,
default: '',
},
});
</script>
<template>
<button
class="inline-flex relative items-center p-1.5 w-fit h-8 gap-1.5 bg-white dark:bg-slate-900 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
class="inline-flex relative items-center p-1.5 w-fit h-8 gap-1.5 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
@click="$emit('click')"
>
<slot name="leftIcon" />
<slot name="leftIcon">
<fluent-icon
v-if="leftIcon"
:icon="leftIcon"
size="18"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/>
</slot>
<span
v-if="buttonText"
class="text-sm font-medium text-slate-900 dark:text-slate-50"
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
>
{{ buttonText }}
</span>
<slot name="rightIcon" />
<div v-if="$slots.dropdown" class="absolute right-0 top-10" @click.stop>
<slot name="dropdown" />
</div>
<slot name="rightIcon">
<fluent-icon
v-if="rightIcon"
:icon="rightIcon"
size="18"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/>
</slot>
<slot name="dropdown" />
</button>
</template>

View File

@@ -1,9 +1,5 @@
<script setup>
defineProps({
buttonText: {
type: String,
default: '',
},
inputValue: {
type: String,
default: '',
@@ -18,7 +14,7 @@ defineProps({
<div
class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-white z-10 dark:bg-slate-800 gap-2 px-3 border-b rounded-t-xl border-slate-50 dark:border-slate-700"
>
<div class="flex items-center gap-2">
<div class="flex items-center w-full gap-2">
<fluent-icon
icon="search"
size="18"
@@ -32,14 +28,16 @@ defineProps({
@input="$emit('input', $event.target.value)"
/>
</div>
<!-- Clear filter button -->
<woot-button
v-if="!inputValue"
size="small"
variant="clear"
color-scheme="primary"
class="!px-1 !py-1.5"
@click="$emit('click')"
>
{{ buttonText }}
{{ $t('REPORT.FILTER_ACTIONS.CLEAR_FILTER') }}
</woot-button>
</div>
</template>

View File

@@ -14,18 +14,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
inputButtonText: {
type: String,
default: '',
},
emptyListMessage: {
type: String,
default: '',
},
inputPlaceholder: {
type: String,
default: '',
},
activeFilterId: {
type: Number,
default: null,
},
});
const searchTerm = ref('');
@@ -42,29 +38,35 @@ const filteredListItems = computed(() => {
const isDropdownListEmpty = computed(() => {
return !filteredListItems.value.length;
});
const isFilterActive = id => {
if (!props.activeFilterId) return false;
return id === props.activeFilterId;
};
</script>
<template>
<div
class="z-20 w-40 bg-white border shadow dark:bg-slate-800 rounded-xl border-slate-50 dark:border-slate-700/50 max-h-72"
class="absolute z-20 w-40 bg-white border shadow dark:bg-slate-800 rounded-xl border-slate-50 dark:border-slate-700/50 max-h-[400px]"
@click.stop
>
<slot name="search">
<filter-dropdown-search
v-if="enableSearch && listItems.length"
:button-text="inputButtonText"
:input-value="searchTerm"
:input-placeholder="inputPlaceholder"
@input="onSearch"
@click="onSearch('')"
@click="$emit('removeFilter')"
/>
</slot>
<slot name="listItem">
<filter-dropdown-empty-state
v-if="isDropdownListEmpty"
:message="emptyListMessage"
:message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')"
/>
<filter-list-item-button
v-for="item in filteredListItems"
:key="item.id"
:is-active="isFilterActive(item.id)"
:button-text="item.name"
@click="$emit('click', item)"
/>

View File

@@ -13,7 +13,7 @@ defineProps({
<template>
<button
class="relative inline-flex items-center justify-start w-full p-3 border-0 rounded-none first:rounded-t-xl last:rounded-b-xl h-11 hover:bg-slate-50 dark:hover:bg-slate-700 active:bg-slate-75 dark:active:bg-slate-800"
@click="$emit('click')"
@click.stop="$emit('click')"
@mouseenter="$emit('mouseenter')"
@mouseleave="$emit('mouseleave')"
@focus="$emit('focus')"

View File

@@ -0,0 +1,210 @@
<template>
<div
class="flex flex-col flex-wrap items-start gap-2 md:items-center md:flex-nowrap md:flex-row"
>
<!-- Active filters section -->
<div v-if="hasActiveFilters" class="flex flex-wrap gap-2 md:flex-nowrap">
<active-filter-chip
v-for="filter in activeFilters"
v-bind="filter"
:key="filter.type"
:placeholder="
$t(
`SLA_REPORTS.DROPDOWN.INPUT_PLACEHOLDER.${filter.type.toUpperCase()}`
)
"
:active-filter-type="activeFilterType"
:show-menu="showSubDropdownMenu"
enable-search
@toggleDropdown="openActiveFilterDropdown"
@closeDropdown="closeActiveFilterDropdown"
@addFilter="addFilter"
@removeFilter="removeFilter"
/>
</div>
<!-- Dividing line between Active filters and Add filter button -->
<div
v-if="hasActiveFilters && !isAllFilterSelected"
class="w-full h-px border md:w-px md:h-5 border-slate-75 dark:border-slate-800"
/>
<!-- Add filter and clear filter button -->
<div class="flex items-center gap-2">
<add-filter-chip
v-if="!isAllFilterSelected"
placeholder-i18n-key="SLA_REPORTS.DROPDOWN.INPUT_PLACEHOLDER"
:name="$t('SLA_REPORTS.DROPDOWN.ADD_FIlTER')"
:menu-option="filterListMenuItems"
:show-menu="showDropdownMenu"
:empty-state-message="$t('SLA_REPORTS.DROPDOWN.NO_FILTER')"
@toggleDropdown="showDropdown"
@closeDropdown="closeDropdown"
@addFilter="addFilter"
/>
<!-- Dividing line between Add filter and Clear all filter button -->
<div
v-if="hasActiveFilters"
class="w-px h-5 border border-slate-75 dark:border-slate-800"
/>
<!-- Clear all filter button -->
<filter-button
v-if="hasActiveFilters"
:button-text="$t('SLA_REPORTS.DROPDOWN.CLEAR_ALL')"
@click="clearAllFilters"
/>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import {
buildFilterList,
getActiveFilter,
getFilterType,
} from './helpers/SLAFilterHelpers';
import FilterButton from '../Filters/v3/FilterButton.vue';
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
export default {
components: {
FilterButton,
ActiveFilterChip,
AddFilterChip,
},
data() {
return {
showDropdownMenu: false,
showSubDropdownMenu: false,
activeFilterType: '',
appliedFilters: {
assigned_agent_id: null,
inbox_id: null,
team_id: null,
sla_policy_id: null,
label_list: null,
},
};
},
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),
}));
},
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('filter-change', this.appliedFilters);
this.resetDropdown();
},
removeFilter(type) {
const filterKey = getFilterType(type, 'typeToKey');
this.appliedFilters[filterKey] = null;
this.$emit('filter-change', this.appliedFilters);
},
clearAllFilters() {
this.appliedFilters = {
assigned_agent_id: null,
inbox_id: null,
team_id: null,
sla_policy_id: null,
label_list: null,
};
this.$emit('filter-change', 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();
},
},
};
</script>

View File

@@ -1,22 +1,25 @@
<template>
<div class="flex flex-col md:flex-row justify-between mb-4">
<div class="md:grid flex flex-col filter-container gap-3 w-full">
<reports-filters-date-range @on-range-change="onDateRangeChange" />
<woot-date-range-picker
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"
/>
</div>
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
<reports-filters-date-range
class="sm:min-w-[200px] tiny h-8"
@on-range-change="onDateRangeChange"
/>
<woot-date-range-picker
v-if="isDateRangeSelected"
show-range
class="no-margin auto-width sm:min-w-[240px] small h-8"
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onCustomDateRangeChange"
/>
<SLA-filter @filter-change="emitFilterChange" />
</div>
</template>
<script>
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
import ReportsFiltersDateRange from '../Filters/DateRange.vue';
import SLAFilter from '../SLA/SLAFilter.vue';
import subDays from 'date-fns/subDays';
import { DATE_RANGE_OPTIONS } from '../../constants';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
@@ -25,6 +28,7 @@ export default {
components: {
WootDateRangePicker,
ReportsFiltersDateRange,
SLAFilter,
},
data() {
@@ -70,8 +74,13 @@ export default {
this.$emit('filter-change', {
from,
to,
...this.selectedGroupByFilter,
});
},
emitFilterChange(params) {
this.selectedGroupByFilter = params;
this.emitChange();
},
onDateRangeChange(selectedRange) {
this.selectedDateRange = selectedRange;
this.emitChange();
@@ -83,9 +92,3 @@ export default {
},
};
</script>
<style scoped>
.filter-container {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
</style>

View File

@@ -0,0 +1,37 @@
export const buildFilterList = (items, type) =>
// Build the filter list for the dropdown
items.map(item => ({
id: item.id,
name: type === 'labels' ? item.title : item.name,
type,
}));
export const getActiveFilter = (filters, type, key) => {
// Method is used to get the active filter from the filter list
return filters.find(filterItem =>
type === 'labels'
? filterItem.title === key
: filterItem.id.toString() === key.toString()
);
};
export const getFilterType = (input, direction) => {
// Method is used to map the filter key to the filter type
const filterMap = {
keyToType: {
assigned_agent_id: 'agents',
inbox_id: 'inboxes',
team_id: 'teams',
sla_policy_id: 'sla',
label_list: 'labels',
},
typeToKey: {
agents: 'assigned_agent_id',
inboxes: 'inbox_id',
teams: 'team_id',
sla: 'sla_policy_id',
labels: 'label_list',
},
};
return filterMap[direction][input];
};