feat: Updated conversation list UI (#12971)
This commit is contained in:
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
menuItems: {
|
||||
@@ -37,9 +38,17 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disableLocalFiltering: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
const emit = defineEmits(['action', 'search']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -57,6 +66,7 @@ const flattenedMenuItems = computed(() => {
|
||||
});
|
||||
|
||||
const filteredMenuItems = computed(() => {
|
||||
if (props.disableLocalFiltering) return props.menuItems;
|
||||
if (!searchQuery.value) return flattenedMenuItems.value;
|
||||
|
||||
return flattenedMenuItems.value.filter(item =>
|
||||
@@ -69,7 +79,7 @@ const filteredMenuSections = computed(() => {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!searchQuery.value) {
|
||||
if (props.disableLocalFiltering || !searchQuery.value) {
|
||||
return props.menuSections;
|
||||
}
|
||||
|
||||
@@ -89,6 +99,12 @@ const filteredMenuSections = computed(() => {
|
||||
.filter(section => section.items.length > 0);
|
||||
});
|
||||
|
||||
const handleSearchInput = event => {
|
||||
if (props.disableLocalFiltering) {
|
||||
emit('search', event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = item => {
|
||||
const { action, value, ...rest } = item;
|
||||
emit('action', { action, value, ...rest });
|
||||
@@ -118,7 +134,7 @@ onMounted(() => {
|
||||
>
|
||||
<div
|
||||
v-if="showSearch"
|
||||
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
|
||||
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
|
||||
>
|
||||
<div class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
@@ -130,6 +146,7 @@ onMounted(() => {
|
||||
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,10 +158,23 @@ onMounted(() => {
|
||||
>
|
||||
<p
|
||||
v-if="section.title"
|
||||
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
|
||||
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
|
||||
:class="showSearch ? 'top-10' : 'top-0'"
|
||||
>
|
||||
{{ section.title }}
|
||||
</p>
|
||||
<div
|
||||
v-if="section.isLoading"
|
||||
class="flex items-center justify-center py-2"
|
||||
>
|
||||
<Spinner :size="24" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!section.items.length && section.emptyState"
|
||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||
>
|
||||
{{ section.emptyState }}
|
||||
</div>
|
||||
<button
|
||||
v-for="(item, itemIndex) in section.items"
|
||||
:key="item.value || itemIndex"
|
||||
@@ -180,6 +210,7 @@ onMounted(() => {
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<slot name="trailing-icon" :item="item" />
|
||||
</button>
|
||||
<div
|
||||
v-if="sectionIndex < filteredMenuSections.length - 1"
|
||||
@@ -188,6 +219,9 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-2">
|
||||
<Spinner :size="24" />
|
||||
</div>
|
||||
<button
|
||||
v-for="(item, index) in filteredMenuItems"
|
||||
:key="index"
|
||||
@@ -223,6 +257,7 @@ onMounted(() => {
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<slot name="trailing-icon" :item="item" />
|
||||
</button>
|
||||
</template>
|
||||
<div
|
||||
@@ -235,5 +270,6 @@ onMounted(() => {
|
||||
: t('DROPDOWN_MENU.EMPTY_STATE')
|
||||
}}
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -39,38 +39,45 @@ const formatFilterValue = value => {
|
||||
|
||||
// Case 2: array → map each item, use name if present, else the item itself
|
||||
if (Array.isArray(value)) {
|
||||
// Empty array
|
||||
if (value.length === 0) return '';
|
||||
return value.map(item => item?.name ?? item).join(', ');
|
||||
}
|
||||
|
||||
// Case 3: object with a "name" property → return name
|
||||
// Case 4: primitive (string, number, etc.) → return as is
|
||||
// Case 3: empty object
|
||||
if (typeof value === 'object' && Object.keys(value).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Case 4: object with a "name" property → return name
|
||||
// Case 5: primitive (string, number, etc.) → return as is
|
||||
return value?.name ?? value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center w-full gap-2 mx-auto">
|
||||
<div class="flex flex-wrap items-center w-full gap-x-1.5 gap-y-2 mx-auto">
|
||||
<template v-for="(filter, index) in appliedFilters" :key="index">
|
||||
<div
|
||||
v-if="index < maxVisibleFilters"
|
||||
class="inline-flex items-center gap-2 h-7"
|
||||
class="inline-flex items-center gap-1.5 h-7"
|
||||
>
|
||||
<div
|
||||
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer"
|
||||
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer bg-n-button-color"
|
||||
@click="emit('openFilter')"
|
||||
>
|
||||
<span
|
||||
class="lowercase whitespace-nowrap first-letter:capitalize text-n-slate-12"
|
||||
class="lowercase whitespace-nowrap font-440 first-letter:capitalize text-n-slate-12"
|
||||
>
|
||||
{{ replaceUnderscoreWithSpace(filter.attributeKey) }}
|
||||
</span>
|
||||
<span class="px-1 text-xs text-n-slate-10 whitespace-nowrap">
|
||||
<span class="px-1 text-xs text-n-slate-11 font-440 whitespace-nowrap">
|
||||
{{ formatOperatorLabel(filter.filterOperator) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="filter.values"
|
||||
v-if="formatFilterValue(filter.values)"
|
||||
:title="formatFilterValue(filter.values)"
|
||||
class="lowercase truncate text-n-slate-12"
|
||||
class="lowercase truncate text-n-slate-12 font-440"
|
||||
:class="{
|
||||
'first-letter:capitalize': shouldCapitalizeFirstLetter(
|
||||
filter.attributeKey
|
||||
@@ -86,7 +93,7 @@ const formatFilterValue = value => {
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="content-center h-full px-1 text-xs font-medium uppercase rounded-lg text-n-slate-10"
|
||||
class="content-center h-full px-1 text-xs font-440 uppercase rounded-lg text-n-slate-11"
|
||||
>
|
||||
{{ filter.queryOperator }}
|
||||
</span>
|
||||
@@ -95,7 +102,7 @@ const formatFilterValue = value => {
|
||||
</template>
|
||||
<div
|
||||
v-if="appliedFilters.length > maxVisibleFilters"
|
||||
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer"
|
||||
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer font-440"
|
||||
@click="emit('openFilter')"
|
||||
>
|
||||
{{ moreFiltersLabel }}
|
||||
@@ -105,9 +112,10 @@ const formatFilterValue = value => {
|
||||
v-if="showClearButton"
|
||||
:label="clearButtonLabel"
|
||||
size="xs"
|
||||
class="!px-1"
|
||||
variant="ghost"
|
||||
class="!px-1 hover:!no-underline"
|
||||
link
|
||||
@click="emit('clearFilters')"
|
||||
/>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, onBeforeUnmount, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useBreakpoints } from '@vueuse/core';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useConversationFilterContext } from './provider.js';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
@@ -42,6 +44,13 @@ const DEFAULT_FILTER = {
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const breakpoints = useBreakpoints({
|
||||
mobile: wootConstants.SMALL_SCREEN_BREAKPOINT,
|
||||
});
|
||||
|
||||
const isSmallScreen = breakpoints.smaller('mobile');
|
||||
const viewInModal = computed(() => isSmallScreen.value);
|
||||
|
||||
const resetFilter = () => {
|
||||
filters.value = [{ ...DEFAULT_FILTER }];
|
||||
};
|
||||
@@ -104,69 +113,78 @@ const outsideClickHandler = [
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="outsideClickHandler"
|
||||
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
|
||||
:class="{
|
||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start justify-center inset-0 px-2 pt-14 pb-8 overflow-y-auto overflow-x-hidden':
|
||||
viewInModal,
|
||||
}"
|
||||
@click.self="viewInModal && emit('close')"
|
||||
>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ filterModalHeaderTitle }}
|
||||
</h3>
|
||||
<div v-if="props.isFolderView">
|
||||
<div class="border-b border-n-weak pb-6">
|
||||
<Input
|
||||
v-model="folderNameLocal"
|
||||
:label="t('FILTER.FOLDER_LABEL')"
|
||||
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
<div
|
||||
v-on-click-outside="viewInModal ? [] : outsideClickHandler"
|
||||
class="z-40 max-w-3xl xl:w-[750px] w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
|
||||
:class="{ 'overflow-x-auto': viewInModal }"
|
||||
>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ filterModalHeaderTitle }}
|
||||
</h3>
|
||||
<div v-if="props.isFolderView">
|
||||
<div class="border-b border-n-weak pb-6">
|
||||
<Input
|
||||
v-model="folderNameLocal"
|
||||
:label="t('FILTER.FOLDER_LABEL')"
|
||||
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="grid gap-4 list-none">
|
||||
<template v-for="(filter, index) in filters" :key="filter.id">
|
||||
<ConditionRow
|
||||
v-if="index === 0"
|
||||
ref="conditionsRef"
|
||||
:key="`filter-${filter.attributeKey}-0`"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
:key="`filter-${filter.attributeKey}-${index}`"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
v-model:values="filter.values"
|
||||
show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="flex gap-2 justify-between">
|
||||
<Button sm ghost blue @click="addFilter">
|
||||
{{ $t('FILTER.ADD_NEW_FILTER') }}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button sm faded slate @click="resetFilter">
|
||||
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isFolderView"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:disabled="!folderNameLocal"
|
||||
@click="updateSavedCustomViews"
|
||||
>
|
||||
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
<Button v-else sm solid blue @click="validateAndSubmit">
|
||||
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
|
||||
<ul class="grid gap-4 list-none">
|
||||
<template v-for="(filter, index) in filters" :key="filter.id">
|
||||
<ConditionRow
|
||||
v-if="index === 0"
|
||||
ref="conditionsRef"
|
||||
:key="`filter-${filter.attributeKey}-0`"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
:key="`filter-${filter.attributeKey}-${index}`"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
v-model:values="filter.values"
|
||||
show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="flex gap-2 justify-between">
|
||||
<Button sm ghost blue @click="addFilter">
|
||||
{{ $t('FILTER.ADD_NEW_FILTER') }}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button sm faded slate @click="resetFilter">
|
||||
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isFolderView"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:disabled="!folderNameLocal"
|
||||
@click="updateSavedCustomViews"
|
||||
>
|
||||
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
<Button v-else sm solid blue @click="validateAndSubmit">
|
||||
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,8 +108,10 @@ export default {
|
||||
<NextInput
|
||||
v-model="name"
|
||||
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
|
||||
:message="v$.name.$error && $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE')"
|
||||
:message-type="v$.name.$error && 'error'"
|
||||
:message="
|
||||
v$.name.$error ? $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') : ''
|
||||
"
|
||||
:message-type="v$.name.$error ? 'error' : 'info'"
|
||||
@blur="v$.name.$touch"
|
||||
/>
|
||||
<div class="flex flex-row justify-end w-full gap-2">
|
||||
|
||||
@@ -117,9 +117,11 @@ const toggleOption = option => {
|
||||
</button>
|
||||
<Button v-else sm slate faded @click="toggle">
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
|
||||
<Icon icon="i-lucide-plus" class="text-n-slate-11 flex-shrink-0" />
|
||||
</template>
|
||||
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
|
||||
<span class="text-n-slate-11 truncate">{{
|
||||
t('COMBOBOX.PLACEHOLDER')
|
||||
}}</span>
|
||||
</Button>
|
||||
</template>
|
||||
<DropdownBody class="top-0 min-w-48 z-50" strong>
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
<script setup>
|
||||
// [TODO] This componet is too big and bulky to be in the same file, we can consider splitting this into multiple
|
||||
// composables and components, useVirtualChatList, useChatlistFilters
|
||||
import {
|
||||
ref,
|
||||
unref,
|
||||
provide,
|
||||
computed,
|
||||
watch,
|
||||
onMounted,
|
||||
defineEmits,
|
||||
} from 'vue';
|
||||
import { ref, provide, computed, watch, onMounted, defineEmits } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
useMapGetter,
|
||||
useFunctionGetter,
|
||||
@@ -19,48 +9,24 @@ import {
|
||||
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import ConversationList from './ConversationList.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||
import SaveCustomView from 'next/filter/SaveCustomView.vue';
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import ChatListFilters from './ChatListFilters.vue';
|
||||
import ConversationActiveFiltersPreview from './widgets/conversation/ConversationActiveFiltersPreview.vue';
|
||||
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
|
||||
import { useFilter } from 'shared/composables/useFilter';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
useCamelCase,
|
||||
useSnakeCase,
|
||||
} from 'dashboard/composables/useTransformKeys';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
|
||||
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
|
||||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
import countries from 'shared/constants/countries';
|
||||
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
||||
import { conversationListPageURL } from '../helper/URLHelper';
|
||||
import {
|
||||
isOnMentionsView,
|
||||
isOnUnattendedView,
|
||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||
import {
|
||||
getUserPermissions,
|
||||
filterItemsByPermission,
|
||||
} from 'dashboard/helper/permissionsHelper.js';
|
||||
import { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers';
|
||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -76,34 +42,20 @@ const props = defineProps({
|
||||
const emit = defineEmits(['conversationLoad']);
|
||||
const { uiSettings } = useUISettings();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||
const activeSortBy = ref(wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC);
|
||||
const chatsOnView = ref([]);
|
||||
const isContextMenuOpen = ref(false);
|
||||
const chatListFiltersRef = ref(null);
|
||||
|
||||
// Provide trigger for label recalculation (parent → child pattern)
|
||||
const recalculateLabelsKey = ref(0);
|
||||
provide('recalculateLabelsKey', recalculateLabelsKey);
|
||||
|
||||
const showAdvancedFilters = ref(false);
|
||||
// chatsOnView is to store the chats that are currently visible on the screen,
|
||||
// which mirrors the conversationList.
|
||||
const chatsOnView = ref([]);
|
||||
const foldersQuery = ref({});
|
||||
const showAddFoldersModal = ref(false);
|
||||
const showDeleteFoldersModal = ref(false);
|
||||
const isContextMenuOpen = ref(false);
|
||||
const appliedFilter = ref([]);
|
||||
const advancedFilterTypes = ref(
|
||||
advancedFilterOptions.map(filter => ({
|
||||
...filter,
|
||||
attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const chatLists = useMapGetter('getFilteredConversations');
|
||||
const mineChatsList = useMapGetter('getMineChats');
|
||||
@@ -113,15 +65,9 @@ const chatListLoading = useMapGetter('getChatListLoadingStatus');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const conversationStats = useMapGetter('conversationStats/getStats');
|
||||
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
|
||||
const folders = useMapGetter('customViews/getConversationCustomViews');
|
||||
const agentList = useMapGetter('agents/getAgents');
|
||||
const teamsList = useMapGetter('teams/getTeams');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
// We can't useFunctionGetter here since it needs to be called on setup?
|
||||
const getTeamFn = useMapGetter('teams/getTeam');
|
||||
const folders = useMapGetter('customViews/getConversationCustomViews');
|
||||
|
||||
const {
|
||||
selectedConversations,
|
||||
@@ -131,55 +77,24 @@ const {
|
||||
selectAllConversations,
|
||||
resetBulkActions,
|
||||
isConversationSelected,
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onAssignTeamsForBulk,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
|
||||
const {
|
||||
initializeStatusAndAssigneeFilterToModal,
|
||||
initializeInboxTeamAndLabelFilterToModal,
|
||||
} = useFilter({
|
||||
filteri18nKey: 'FILTER',
|
||||
attributeModel: 'conversation_attribute',
|
||||
});
|
||||
|
||||
// computed
|
||||
// computed
|
||||
|
||||
const hasAppliedFilters = computed(() => {
|
||||
return appliedFilters.value.length !== 0;
|
||||
});
|
||||
|
||||
const activeFolder = computed(() => {
|
||||
if (props.foldersId) {
|
||||
const activeView = folders.value.filter(
|
||||
view => view.id === Number(props.foldersId)
|
||||
);
|
||||
const [firstValue] = activeView;
|
||||
return firstValue;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const activeFolderName = computed(() => {
|
||||
return activeFolder.value?.name;
|
||||
});
|
||||
|
||||
const hasActiveFolders = computed(() => {
|
||||
return Boolean(activeFolder.value && props.foldersId !== 0);
|
||||
return Boolean(props.foldersId !== 0);
|
||||
});
|
||||
|
||||
const activeFolder = computed(() => {
|
||||
return chatListFiltersRef.value?.activeFolder;
|
||||
});
|
||||
|
||||
const hasAppliedFiltersOrActiveFolders = computed(() => {
|
||||
return hasAppliedFilters.value || hasActiveFolders.value;
|
||||
});
|
||||
|
||||
const currentUserDetails = computed(() => {
|
||||
const { id, name } = currentUser.value;
|
||||
return { id, name };
|
||||
});
|
||||
|
||||
const userPermissions = computed(() => {
|
||||
return getUserPermissions(currentUser.value, currentAccountId.value);
|
||||
});
|
||||
@@ -223,11 +138,6 @@ const hasCurrentPageEndReached = useFunctionGetter(
|
||||
currentPageFilterKey
|
||||
);
|
||||
|
||||
const conversationCustomAttributes = useFunctionGetter(
|
||||
'attributes/getAttributesByModel',
|
||||
'conversation_attribute'
|
||||
);
|
||||
|
||||
const activeAssigneeTabCount = computed(() => {
|
||||
const count = assigneeTabItems.value.find(
|
||||
item => item.key === activeAssigneeTab.value
|
||||
@@ -297,7 +207,7 @@ const pageTitle = computed(() => {
|
||||
if (props.conversationType === 'unattended') {
|
||||
return t('CHAT_LIST.UNATTENDED_HEADING');
|
||||
}
|
||||
if (hasActiveFolders.value) {
|
||||
if (hasActiveFolders.value && activeFolder.value?.name) {
|
||||
return activeFolder.value.name;
|
||||
}
|
||||
return t('CHAT_LIST.TAB_HEADING');
|
||||
@@ -319,7 +229,7 @@ const conversationList = computed(() => {
|
||||
localConversationList = [...chatLists.value];
|
||||
}
|
||||
|
||||
if (activeFolder.value) {
|
||||
if (activeFolder.value?.query?.payload) {
|
||||
const { payload } = activeFolder.value.query;
|
||||
localConversationList = localConversationList.filter(conversation => {
|
||||
return matchesFilters(conversation, payload);
|
||||
@@ -364,205 +274,71 @@ function setFiltersFromUISettings() {
|
||||
|
||||
function emitConversationLoaded() {
|
||||
emit('conversationLoad');
|
||||
// [VITE] removing this since the library has changed
|
||||
// nextTick(() => {
|
||||
// // Addressing a known issue in the virtual list library where dynamically added items
|
||||
// // might not render correctly. This workaround involves a slight manual adjustment
|
||||
// // to the scroll position, triggering the list to refresh its rendering.
|
||||
// const virtualList = conversationListRef.value;
|
||||
// const scrollToOffset = virtualList?.scrollToOffset;
|
||||
// const currentOffset = virtualList?.getOffset() || 0;
|
||||
// if (scrollToOffset) {
|
||||
// scrollToOffset(currentOffset + 1);
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
function fetchFilteredConversations(payload) {
|
||||
payload = useSnakeCase(payload);
|
||||
const transformedPayload = useSnakeCase(payload);
|
||||
let page = currentFiltersPage.value + 1;
|
||||
store
|
||||
.dispatch('fetchFilteredConversations', {
|
||||
queryData: filterQueryGenerator(payload),
|
||||
queryData: filterQueryGenerator(transformedPayload),
|
||||
page,
|
||||
})
|
||||
.then(emitConversationLoaded);
|
||||
|
||||
showAdvancedFilters.value = false;
|
||||
}
|
||||
|
||||
function fetchSavedFilteredConversations(payload) {
|
||||
payload = useSnakeCase(payload);
|
||||
const transformedPayload = useSnakeCase(payload);
|
||||
let page = currentFiltersPage.value + 1;
|
||||
store
|
||||
.dispatch('fetchFilteredConversations', {
|
||||
queryData: payload,
|
||||
queryData: transformedPayload,
|
||||
page,
|
||||
})
|
||||
.then(emitConversationLoaded);
|
||||
}
|
||||
|
||||
function onApplyFilter(payload) {
|
||||
payload = useSnakeCase(payload);
|
||||
resetBulkActions();
|
||||
foldersQuery.value = filterQueryGenerator(payload);
|
||||
store.dispatch('conversationPage/reset');
|
||||
store.dispatch('emptyAllConversations');
|
||||
fetchFilteredConversations(payload);
|
||||
}
|
||||
|
||||
function closeAdvanceFiltersModal() {
|
||||
showAdvancedFilters.value = false;
|
||||
appliedFilter.value = [];
|
||||
}
|
||||
|
||||
function onUpdateSavedFilter(payload, folderName) {
|
||||
const transformedPayload = useSnakeCase(payload);
|
||||
const payloadData = {
|
||||
...unref(activeFolder),
|
||||
name: unref(folderName),
|
||||
query: filterQueryGenerator(transformedPayload),
|
||||
};
|
||||
store.dispatch('customViews/update', payloadData);
|
||||
closeAdvanceFiltersModal();
|
||||
}
|
||||
|
||||
function onClickOpenAddFoldersModal() {
|
||||
showAddFoldersModal.value = true;
|
||||
}
|
||||
|
||||
function onCloseAddFoldersModal() {
|
||||
showAddFoldersModal.value = false;
|
||||
}
|
||||
|
||||
function onClickOpenDeleteFoldersModal() {
|
||||
showDeleteFoldersModal.value = true;
|
||||
}
|
||||
|
||||
function onCloseDeleteFoldersModal() {
|
||||
showDeleteFoldersModal.value = false;
|
||||
}
|
||||
|
||||
function setParamsForEditFolderModal() {
|
||||
// Here we are setting the params for edit folder modal to show the existing values.
|
||||
|
||||
// For agent, team, inboxes,and campaigns we get only the id's from the query.
|
||||
// So we are mapping the id's to the actual values.
|
||||
|
||||
// For labels we get the name of the label from the query.
|
||||
// If we delete the label from the label list then we will not be able to show the label name.
|
||||
|
||||
// For custom attributes we get only attribute key.
|
||||
// So we are mapping it to find the input type of the attribute to show in the edit folder modal.
|
||||
return {
|
||||
agents: agentList.value,
|
||||
teams: teamsList.value,
|
||||
inboxes: inboxesList.value,
|
||||
labels: labels.value,
|
||||
campaigns: campaigns.value,
|
||||
languages: languages,
|
||||
countries: countries,
|
||||
priority: [
|
||||
{ id: 'low', name: t('CONVERSATION.PRIORITY.OPTIONS.LOW') },
|
||||
{ id: 'medium', name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM') },
|
||||
{ id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
|
||||
{ id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
|
||||
],
|
||||
filterTypes: advancedFilterTypes.value,
|
||||
allCustomAttributes: conversationCustomAttributes.value,
|
||||
};
|
||||
}
|
||||
|
||||
function initializeExistingFilterToModal() {
|
||||
const statusFilter = initializeStatusAndAssigneeFilterToModal(
|
||||
activeStatus.value,
|
||||
currentUserDetails.value,
|
||||
activeAssigneeTab.value
|
||||
);
|
||||
// TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
|
||||
if (statusFilter) {
|
||||
appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)];
|
||||
}
|
||||
|
||||
// TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
|
||||
const otherFilters = initializeInboxTeamAndLabelFilterToModal(
|
||||
props.conversationInbox,
|
||||
inbox.value,
|
||||
props.teamId,
|
||||
activeTeam.value,
|
||||
props.label
|
||||
).map(useCamelCase);
|
||||
|
||||
appliedFilter.value = [...appliedFilter.value, ...otherFilters];
|
||||
}
|
||||
|
||||
function initializeFolderToFilterModal(newActiveFolder) {
|
||||
// Here we are setting the params for edit folder modal.
|
||||
// To show the existing values. when we click on edit folder button.
|
||||
|
||||
// Here we get the query from the active folder.
|
||||
// And we are mapping the query to the actual values.
|
||||
// To show in the edit folder modal by the help of generateValuesForEditCustomViews helper.
|
||||
const query = unref(newActiveFolder)?.query?.payload;
|
||||
if (!Array.isArray(query)) return;
|
||||
|
||||
const newFilters = query.map(filter => {
|
||||
const transformed = useCamelCase(filter);
|
||||
const values = Array.isArray(transformed.values)
|
||||
? generateValuesForEditCustomViews(
|
||||
useSnakeCase(filter),
|
||||
setParamsForEditFolderModal()
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
attributeKey: transformed.attributeKey,
|
||||
attributeModel: transformed.attributeModel,
|
||||
customAttributeType: transformed.customAttributeType,
|
||||
filterOperator: transformed.filterOperator,
|
||||
queryOperator: transformed.queryOperator ?? 'and',
|
||||
values,
|
||||
};
|
||||
});
|
||||
|
||||
appliedFilter.value = [...appliedFilter.value, ...newFilters];
|
||||
}
|
||||
|
||||
function initalizeAppliedFiltersToModal() {
|
||||
appliedFilter.value = [...appliedFilters.value];
|
||||
}
|
||||
|
||||
function onToggleAdvanceFiltersModal() {
|
||||
if (showAdvancedFilters.value === true) {
|
||||
closeAdvanceFiltersModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAppliedFilters.value && !hasActiveFolders.value) {
|
||||
initializeExistingFilterToModal();
|
||||
}
|
||||
if (hasActiveFolders.value) {
|
||||
initializeFolderToFilterModal(activeFolder.value);
|
||||
}
|
||||
if (hasAppliedFilters.value) {
|
||||
initalizeAppliedFiltersToModal();
|
||||
}
|
||||
|
||||
showAdvancedFilters.value = true;
|
||||
}
|
||||
|
||||
function fetchConversations() {
|
||||
store.dispatch('updateChatListFilters', conversationFilters.value);
|
||||
store.dispatch('fetchAllConversations').then(emitConversationLoaded);
|
||||
}
|
||||
|
||||
function onApplyFilter(payload) {
|
||||
resetBulkActions();
|
||||
store.dispatch('conversationPage/reset');
|
||||
store.dispatch('emptyAllConversations');
|
||||
fetchFilteredConversations(payload);
|
||||
}
|
||||
|
||||
function onOpenAddFolders() {
|
||||
const lastItemOfFolder = folders.value[folders.value.length - 1];
|
||||
const lastItemId = lastItemOfFolder.id;
|
||||
router.push({
|
||||
name: 'folder_conversations',
|
||||
params: { id: lastItemId },
|
||||
});
|
||||
}
|
||||
|
||||
function onOpenDeleteFolders() {
|
||||
if (folders.value.length > 0) {
|
||||
const lastItemOfFolder = folders.value[folders.value.length - 1];
|
||||
const lastItemId = lastItemOfFolder.id;
|
||||
router.push({
|
||||
name: 'folder_conversations',
|
||||
params: { id: lastItemId },
|
||||
});
|
||||
} else {
|
||||
router.push({ name: 'home' });
|
||||
fetchConversations();
|
||||
}
|
||||
}
|
||||
|
||||
function resetAndFetchData() {
|
||||
appliedFilter.value = [];
|
||||
resetBulkActions();
|
||||
store.dispatch('conversationPage/reset');
|
||||
store.dispatch('emptyAllConversations');
|
||||
store.dispatch('clearConversationFilters');
|
||||
if (hasActiveFolders.value) {
|
||||
if (hasActiveFolders.value && activeFolder.value?.query) {
|
||||
const payload = activeFolder.value.query;
|
||||
fetchSavedFilteredConversations(payload);
|
||||
}
|
||||
@@ -579,7 +355,7 @@ function loadMoreConversations() {
|
||||
|
||||
if (!hasAppliedFiltersOrActiveFolders.value) {
|
||||
fetchConversations();
|
||||
} else if (hasActiveFolders.value) {
|
||||
} else if (hasActiveFolders.value && activeFolder.value?.query) {
|
||||
const payload = activeFolder.value.query;
|
||||
fetchSavedFilteredConversations(payload);
|
||||
} else if (hasAppliedFilters.value) {
|
||||
@@ -609,116 +385,6 @@ function onBasicFilterChange(value, type) {
|
||||
resetAndFetchData();
|
||||
}
|
||||
|
||||
function openLastSavedItemInFolder() {
|
||||
const lastItemOfFolder = folders.value[folders.value.length - 1];
|
||||
const lastItemId = lastItemOfFolder.id;
|
||||
router.push({
|
||||
name: 'folder_conversations',
|
||||
params: { id: lastItemId },
|
||||
});
|
||||
}
|
||||
|
||||
function openLastItemAfterDeleteInFolder() {
|
||||
if (folders.value.length > 0) {
|
||||
openLastSavedItemInFolder();
|
||||
} else {
|
||||
router.push({ name: 'home' });
|
||||
fetchConversations();
|
||||
}
|
||||
}
|
||||
|
||||
function redirectToConversationList() {
|
||||
const {
|
||||
params: { accountId, inbox_id: inboxId, label, teamId },
|
||||
name,
|
||||
} = route;
|
||||
|
||||
let conversationType = '';
|
||||
if (isOnMentionsView({ route: { name } })) {
|
||||
conversationType = 'mention';
|
||||
} else if (isOnUnattendedView({ route: { name } })) {
|
||||
conversationType = 'unattended';
|
||||
}
|
||||
router.push(
|
||||
conversationListPageURL({
|
||||
accountId,
|
||||
conversationType: conversationType,
|
||||
customViewId: props.foldersId,
|
||||
inboxId,
|
||||
label,
|
||||
teamId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function assignPriority(priority, conversationId = null) {
|
||||
store.dispatch('setCurrentChatPriority', {
|
||||
priority,
|
||||
conversationId,
|
||||
});
|
||||
store.dispatch('assignPriority', { conversationId, priority }).then(() => {
|
||||
useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
|
||||
newValue: priority,
|
||||
from: 'Context menu',
|
||||
});
|
||||
useAlert(
|
||||
t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
|
||||
priority,
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function markAsUnread(conversationId) {
|
||||
try {
|
||||
await store.dispatch('markMessagesUnread', {
|
||||
id: conversationId,
|
||||
});
|
||||
redirectToConversationList();
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
async function markAsRead(conversationId) {
|
||||
try {
|
||||
await store.dispatch('markMessagesRead', {
|
||||
id: conversationId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
|
||||
async function onAssignTeam(team, conversationId = null) {
|
||||
try {
|
||||
await store.dispatch('assignTeam', {
|
||||
conversationId,
|
||||
teamId: team.id,
|
||||
});
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.SUCCESFUL', {
|
||||
team: team.name,
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.FAILED'));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleConversationStatus(conversationId, status, snoozedUntil) {
|
||||
store
|
||||
.dispatch('toggleStatus', {
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
});
|
||||
}
|
||||
|
||||
function allSelectedConversationsStatus(status) {
|
||||
if (!selectedConversations.value.length) return false;
|
||||
return selectedConversations.value.every(item => {
|
||||
@@ -750,45 +416,19 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const deleteConversationDialogRef = ref(null);
|
||||
const selectedConversationId = ref(null);
|
||||
|
||||
async function deleteConversation() {
|
||||
try {
|
||||
await store.dispatch('deleteConversation', selectedConversationId.value);
|
||||
redirectToConversationList();
|
||||
selectedConversationId.value = null;
|
||||
deleteConversationDialogRef.value.close();
|
||||
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = conversationId => {
|
||||
selectedConversationId.value = conversationId;
|
||||
deleteConversationDialogRef.value.open();
|
||||
};
|
||||
|
||||
provide('selectConversation', selectConversation);
|
||||
provide('deSelectConversation', deSelectConversation);
|
||||
provide('assignAgent', onAssignAgent);
|
||||
provide('assignTeam', onAssignTeam);
|
||||
provide('assignLabels', onAssignLabels);
|
||||
provide('updateConversationStatus', toggleConversationStatus);
|
||||
provide('toggleContextMenu', onContextMenuToggle);
|
||||
provide('markAsUnread', markAsUnread);
|
||||
provide('markAsRead', markAsRead);
|
||||
provide('assignPriority', assignPriority);
|
||||
provide('isConversationSelected', isConversationSelected);
|
||||
provide('deleteConversation', handleDelete);
|
||||
|
||||
watch(activeTeam, () => resetAndFetchData());
|
||||
|
||||
watch(
|
||||
computed(() => props.conversationInbox),
|
||||
() => resetAndFetchData()
|
||||
);
|
||||
watch(
|
||||
computed(() => props.teamId),
|
||||
() => resetAndFetchData()
|
||||
);
|
||||
watch(
|
||||
computed(() => props.label),
|
||||
() => resetAndFetchData()
|
||||
@@ -797,6 +437,10 @@ watch(
|
||||
computed(() => props.conversationType),
|
||||
() => resetAndFetchData()
|
||||
);
|
||||
watch(
|
||||
computed(() => props.foldersId),
|
||||
() => resetAndFetchData()
|
||||
);
|
||||
|
||||
watch(activeFolder, (newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
@@ -833,32 +477,24 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
:conversation-stats="conversationStats"
|
||||
:is-list-loading="chatListLoading && !conversationList.length"
|
||||
@add-folders="onClickOpenAddFoldersModal"
|
||||
@delete-folders="onClickOpenDeleteFoldersModal"
|
||||
@filters-modal="onToggleAdvanceFiltersModal"
|
||||
@reset-filters="resetAndFetchData"
|
||||
@filters-modal="chatListFiltersRef?.onToggleAdvanceFiltersModal"
|
||||
@basic-filter-change="onBasicFilterChange"
|
||||
/>
|
||||
|
||||
<TeleportWithDirection
|
||||
v-if="showAddFoldersModal"
|
||||
to="#saveFilterTeleportTarget"
|
||||
>
|
||||
<SaveCustomView
|
||||
v-model="appliedFilter"
|
||||
:custom-views-query="foldersQuery"
|
||||
:open-last-saved-item="openLastSavedItemInFolder"
|
||||
@close="onCloseAddFoldersModal"
|
||||
/>
|
||||
</TeleportWithDirection>
|
||||
|
||||
<DeleteCustomViews
|
||||
v-if="showDeleteFoldersModal"
|
||||
v-model:show="showDeleteFoldersModal"
|
||||
:active-custom-view="activeFolder"
|
||||
:custom-views-id="foldersId"
|
||||
:open-last-item-after-delete="openLastItemAfterDeleteInFolder"
|
||||
@close="onCloseDeleteFoldersModal"
|
||||
<ChatListFilters
|
||||
ref="chatListFiltersRef"
|
||||
:conversation-inbox="conversationInbox"
|
||||
:team-id="teamId"
|
||||
:label="label"
|
||||
:folders-id="foldersId"
|
||||
:active-status="activeStatus"
|
||||
:active-assignee-tab="activeAssigneeTab"
|
||||
:current-user-details="{ id: currentUser.id, name: currentUser.name }"
|
||||
:has-active-folders="hasActiveFolders"
|
||||
:has-applied-filters="hasAppliedFilters"
|
||||
@apply-filter="onApplyFilter"
|
||||
@open-add-folders="onOpenAddFolders"
|
||||
@open-delete-folders="onOpenDeleteFolders"
|
||||
/>
|
||||
|
||||
<ChatTypeTabs
|
||||
@@ -868,13 +504,15 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
is-compact
|
||||
@chat-tab-change="updateAssigneeTab"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="!chatListLoading && !conversationList.length"
|
||||
class="flex items-center justify-center p-4 overflow-auto"
|
||||
>
|
||||
{{ $t('CHAT_LIST.LIST.404') }}
|
||||
</p>
|
||||
<ConversationActiveFiltersPreview
|
||||
:active-folder="activeFolder"
|
||||
:has-active-folders="hasActiveFolders"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@clear-filters="resetAndFetchData"
|
||||
@open-filter="chatListFiltersRef?.onToggleAdvanceFiltersModal"
|
||||
@save-folder="chatListFiltersRef?.onClickOpenAddFoldersModal"
|
||||
@delete-folder="chatListFiltersRef?.onClickOpenDeleteFoldersModal"
|
||||
/>
|
||||
<ConversationBulkActions
|
||||
v-if="selectedConversations.length"
|
||||
:conversations="selectedConversations"
|
||||
@@ -884,11 +522,15 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
:show-resolved-action="allSelectedConversationsStatus('resolved')"
|
||||
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
|
||||
@select-all-conversations="toggleSelectAll"
|
||||
@assign-agent="onAssignAgent"
|
||||
@update-conversations="onUpdateConversations"
|
||||
@assign-labels="onAssignLabels"
|
||||
@assign-team="onAssignTeamsForBulk"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="!chatListLoading && !conversationList.length"
|
||||
class="flex items-center justify-center p-4 overflow-auto"
|
||||
>
|
||||
{{ $t('CHAT_LIST.LIST.404') }}
|
||||
</p>
|
||||
|
||||
<ConversationList
|
||||
:items="conversationList"
|
||||
:is-loading="chatListLoading"
|
||||
@@ -902,32 +544,5 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
:is-expanded-layout="isOnExpandedLayout"
|
||||
@load-more="loadMoreConversations"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
type="alert"
|
||||
:title="
|
||||
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
|
||||
conversationId: selectedConversationId,
|
||||
})
|
||||
"
|
||||
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
|
||||
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
|
||||
@confirm="deleteConversation"
|
||||
@close="selectedConversationId = null"
|
||||
/>
|
||||
<TeleportWithDirection
|
||||
v-if="showAdvancedFilters"
|
||||
to="#conversationFilterTeleportTarget"
|
||||
>
|
||||
<ConversationFilter
|
||||
v-model="appliedFilter"
|
||||
:folder-name="activeFolderName"
|
||||
:is-folder-view="hasActiveFolders"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-folder="onUpdateSavedFilter"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
/>
|
||||
</TeleportWithDirection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
<script setup>
|
||||
import { ref, unref, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
useMapGetter,
|
||||
useFunctionGetter,
|
||||
} from 'dashboard/composables/store.js';
|
||||
import {
|
||||
useCamelCase,
|
||||
useSnakeCase,
|
||||
} from 'dashboard/composables/useTransformKeys';
|
||||
import { useFilter } from 'shared/composables/useFilter';
|
||||
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||
import SaveCustomView from 'next/filter/SaveCustomView.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
|
||||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||
import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
import countries from 'shared/constants/countries';
|
||||
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
||||
|
||||
const props = defineProps({
|
||||
conversationInbox: { type: [String, Number], default: 0 },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
label: { type: String, default: '' },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
activeStatus: { type: String, required: true },
|
||||
activeAssigneeTab: { type: String, required: true },
|
||||
currentUserDetails: { type: Object, required: true },
|
||||
hasActiveFolders: { type: Boolean, default: false },
|
||||
hasAppliedFilters: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'openAddFolders',
|
||||
'openDeleteFolders',
|
||||
]);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const showAdvancedFilters = ref(false);
|
||||
const showAddFoldersModal = ref(false);
|
||||
const showDeleteFoldersModal = ref(false);
|
||||
const appliedFilter = ref([]);
|
||||
const foldersQuery = ref({});
|
||||
|
||||
const advancedFilterTypes = ref(
|
||||
advancedFilterOptions.map(filter => ({
|
||||
...filter,
|
||||
attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const folders = useMapGetter('customViews/getConversationCustomViews');
|
||||
const agentList = useMapGetter('agents/getAgents');
|
||||
const teamsList = useMapGetter('teams/getTeams');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const getTeamFn = useMapGetter('teams/getTeam');
|
||||
|
||||
const inbox = useFunctionGetter('inboxes/getInbox', activeInbox);
|
||||
const conversationCustomAttributes = useFunctionGetter(
|
||||
'attributes/getAttributesByModel',
|
||||
'conversation_attribute'
|
||||
);
|
||||
|
||||
const activeFolder = computed(() => {
|
||||
if (props.foldersId) {
|
||||
const activeView = folders.value.filter(
|
||||
view => view.id === Number(props.foldersId)
|
||||
);
|
||||
const [firstValue] = activeView;
|
||||
return firstValue;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const activeFolderName = computed(() => {
|
||||
return activeFolder.value?.name;
|
||||
});
|
||||
|
||||
const activeTeam = computed(() => {
|
||||
if (props.teamId) {
|
||||
return getTeamFn.value(props.teamId);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const {
|
||||
initializeStatusAndAssigneeFilterToModal,
|
||||
initializeInboxTeamAndLabelFilterToModal,
|
||||
} = useFilter({
|
||||
filteri18nKey: 'FILTER',
|
||||
attributeModel: 'conversation_attribute',
|
||||
});
|
||||
|
||||
function closeAdvanceFiltersModal() {
|
||||
showAdvancedFilters.value = false;
|
||||
appliedFilter.value = [];
|
||||
}
|
||||
|
||||
function onApplyFilter(payload) {
|
||||
const transformedPayload = useSnakeCase(payload);
|
||||
foldersQuery.value = filterQueryGenerator(transformedPayload);
|
||||
emit('applyFilter', transformedPayload);
|
||||
showAdvancedFilters.value = false;
|
||||
}
|
||||
|
||||
function onUpdateSavedFilter(payload, folderName) {
|
||||
const transformedPayload = useSnakeCase(payload);
|
||||
const payloadData = {
|
||||
...unref(activeFolder),
|
||||
name: unref(folderName),
|
||||
query: filterQueryGenerator(transformedPayload),
|
||||
};
|
||||
store.dispatch('customViews/update', payloadData);
|
||||
closeAdvanceFiltersModal();
|
||||
}
|
||||
|
||||
function onClickOpenAddFoldersModal() {
|
||||
showAddFoldersModal.value = true;
|
||||
}
|
||||
|
||||
function onCloseAddFoldersModal() {
|
||||
showAddFoldersModal.value = false;
|
||||
}
|
||||
|
||||
function onClickOpenDeleteFoldersModal() {
|
||||
showDeleteFoldersModal.value = true;
|
||||
}
|
||||
|
||||
function onCloseDeleteFoldersModal() {
|
||||
showDeleteFoldersModal.value = false;
|
||||
}
|
||||
|
||||
function openLastSavedItemInFolder() {
|
||||
emit('openAddFolders');
|
||||
}
|
||||
|
||||
function openLastItemAfterDeleteInFolder() {
|
||||
emit('openDeleteFolders');
|
||||
}
|
||||
|
||||
function setParamsForEditFolderModal() {
|
||||
// Here we are setting the params for edit folder modal to show the existing values.
|
||||
|
||||
// For agent, team, inboxes,and campaigns we get only the id's from the query.
|
||||
// So we are mapping the id's to the actual values.
|
||||
|
||||
// For labels we get the name of the label from the query.
|
||||
// If we delete the label from the label list then we will not be able to show the label name.
|
||||
|
||||
// For custom attributes we get only attribute key.
|
||||
// So we are mapping it to find the input type of the attribute to show in the edit folder modal.
|
||||
return {
|
||||
agents: agentList.value,
|
||||
teams: teamsList.value,
|
||||
inboxes: inboxesList.value,
|
||||
labels: labels.value,
|
||||
campaigns: campaigns.value,
|
||||
languages: languages,
|
||||
countries: countries,
|
||||
priority: [
|
||||
{ id: 'low', name: t('CONVERSATION.PRIORITY.OPTIONS.LOW') },
|
||||
{ id: 'medium', name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM') },
|
||||
{ id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
|
||||
{ id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
|
||||
],
|
||||
filterTypes: advancedFilterTypes.value,
|
||||
allCustomAttributes: conversationCustomAttributes.value,
|
||||
};
|
||||
}
|
||||
|
||||
function initializeExistingFilterToModal() {
|
||||
const statusFilter = initializeStatusAndAssigneeFilterToModal(
|
||||
props.activeStatus,
|
||||
props.currentUserDetails,
|
||||
props.activeAssigneeTab
|
||||
);
|
||||
if (statusFilter) {
|
||||
appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)];
|
||||
}
|
||||
|
||||
const otherFilters = initializeInboxTeamAndLabelFilterToModal(
|
||||
props.conversationInbox,
|
||||
inbox.value,
|
||||
props.teamId,
|
||||
activeTeam.value,
|
||||
props.label
|
||||
).map(useCamelCase);
|
||||
|
||||
appliedFilter.value = [...appliedFilter.value, ...otherFilters];
|
||||
}
|
||||
|
||||
function initializeFolderToFilterModal(newActiveFolder) {
|
||||
const query = unref(newActiveFolder)?.query?.payload;
|
||||
if (!Array.isArray(query)) return;
|
||||
|
||||
const newFilters = query.map(filter => {
|
||||
const transformed = useCamelCase(filter);
|
||||
const values = Array.isArray(transformed.values)
|
||||
? generateValuesForEditCustomViews(
|
||||
useSnakeCase(filter),
|
||||
setParamsForEditFolderModal()
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
attributeKey: transformed.attributeKey,
|
||||
attributeModel: transformed.attributeModel,
|
||||
customAttributeType: transformed.customAttributeType,
|
||||
filterOperator: transformed.filterOperator,
|
||||
queryOperator: transformed.queryOperator ?? 'and',
|
||||
values,
|
||||
};
|
||||
});
|
||||
|
||||
appliedFilter.value = [...appliedFilter.value, ...newFilters];
|
||||
}
|
||||
|
||||
function initalizeAppliedFiltersToModal() {
|
||||
appliedFilter.value = [...appliedFilters.value];
|
||||
}
|
||||
|
||||
function onToggleAdvanceFiltersModal() {
|
||||
if (showAdvancedFilters.value === true) {
|
||||
closeAdvanceFiltersModal();
|
||||
return;
|
||||
}
|
||||
|
||||
appliedFilter.value = [];
|
||||
|
||||
if (!props.hasAppliedFilters && !props.hasActiveFolders) {
|
||||
initializeExistingFilterToModal();
|
||||
}
|
||||
if (props.hasActiveFolders) {
|
||||
initializeFolderToFilterModal(activeFolder.value);
|
||||
}
|
||||
if (props.hasAppliedFilters) {
|
||||
initalizeAppliedFiltersToModal();
|
||||
}
|
||||
|
||||
showAdvancedFilters.value = true;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
onToggleAdvanceFiltersModal,
|
||||
onClickOpenAddFoldersModal,
|
||||
onClickOpenDeleteFoldersModal,
|
||||
onCloseAddFoldersModal,
|
||||
onCloseDeleteFoldersModal,
|
||||
foldersQuery,
|
||||
activeFolder,
|
||||
appliedFilter,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<TeleportWithDirection
|
||||
v-if="showAddFoldersModal"
|
||||
to="#saveFilterTeleportTarget"
|
||||
>
|
||||
<SaveCustomView
|
||||
v-model="appliedFilter"
|
||||
:custom-views-query="foldersQuery"
|
||||
:open-last-saved-item="openLastSavedItemInFolder"
|
||||
@close="onCloseAddFoldersModal"
|
||||
/>
|
||||
</TeleportWithDirection>
|
||||
|
||||
<TeleportWithDirection
|
||||
v-if="showDeleteFoldersModal"
|
||||
to="#deleteFilterTeleportTarget"
|
||||
>
|
||||
<DeleteCustomViews
|
||||
v-model:show="showDeleteFoldersModal"
|
||||
:active-custom-view="activeFolder"
|
||||
:custom-views-id="foldersId"
|
||||
:open-last-item-after-delete="openLastItemAfterDeleteInFolder"
|
||||
@close="onCloseDeleteFoldersModal"
|
||||
/>
|
||||
</TeleportWithDirection>
|
||||
|
||||
<TeleportWithDirection
|
||||
v-if="showAdvancedFilters"
|
||||
to="#conversationFilterTeleportTarget"
|
||||
>
|
||||
<ConversationFilter
|
||||
v-model="appliedFilter"
|
||||
:folder-name="activeFolderName"
|
||||
:is-folder-view="hasActiveFolders"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-folder="onUpdateSavedFilter"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
/>
|
||||
</TeleportWithDirection>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,13 +18,7 @@ const props = defineProps({
|
||||
isListLoading: { type: Boolean, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'addFolders',
|
||||
'deleteFolders',
|
||||
'resetFilters',
|
||||
'basicFilterChange',
|
||||
'filtersModal',
|
||||
]);
|
||||
const emit = defineEmits(['basicFilterChange', 'filtersModal']);
|
||||
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
@@ -56,12 +50,7 @@ const toggleConversationLayout = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 h-[3.25rem]"
|
||||
:class="{
|
||||
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 px-4 h-[3.25rem]">
|
||||
<div class="flex items-center justify-center min-w-0">
|
||||
<h1
|
||||
class="text-base font-medium truncate text-n-slate-12"
|
||||
@@ -73,44 +62,24 @@ const toggleConversationLayout = () => {
|
||||
v-if="
|
||||
allCount > 0 && hasAppliedFiltersOrActiveFolders && !isListLoading
|
||||
"
|
||||
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
|
||||
class="px-2 py-1 my-0.5 mx-1 rounded-lg capitalize -outline-offset-1 outline outline-1 outline-n-container bg-n-button-color text-xxs text-n-slate-12 shrink-0"
|
||||
:title="allCount"
|
||||
>
|
||||
{{ formattedAllCount }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
|
||||
class="px-2 py-1 my-0.5 mx-1 rounded-lg capitalize -outline-offset-1 outline outline-1 outline-n-container bg-n-button-color text-xxs text-n-slate-12 shrink-0"
|
||||
>
|
||||
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-if="hasAppliedFilters && !hasActiveFolders">
|
||||
<div class="relative">
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
|
||||
icon="i-lucide-save"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="emit('addFolders')"
|
||||
/>
|
||||
<div
|
||||
id="saveFilterTeleportTarget"
|
||||
class="absolute z-50 mt-2"
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
|
||||
icon="i-lucide-circle-x"
|
||||
ruby
|
||||
faded
|
||||
xs
|
||||
@click="emit('resetFilters')"
|
||||
/>
|
||||
</template>
|
||||
<div class="flex items-center gap-2">
|
||||
<ConversationBasicFilter
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@change-filter="onBasicFilterChange"
|
||||
/>
|
||||
<template v-if="hasActiveFolders">
|
||||
<div class="relative">
|
||||
<NextButton
|
||||
@@ -128,15 +97,6 @@ const toggleConversationLayout = () => {
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
id="toggleConversationFilterButton"
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
|
||||
icon="i-lucide-trash-2"
|
||||
ruby
|
||||
xs
|
||||
faded
|
||||
@click="emit('deleteFolders')"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="relative">
|
||||
<NextButton
|
||||
@@ -154,11 +114,6 @@ const toggleConversationLayout = () => {
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
<ConversationBasicFilter
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@change-filter="onBasicFilterChange"
|
||||
/>
|
||||
<SwitchLayout
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@toggle="toggleConversationLayout"
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
import { inject, ref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
|
||||
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
source: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -33,18 +40,126 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectConversation = inject('selectConversation');
|
||||
const deSelectConversation = inject('deSelectConversation');
|
||||
const assignAgent = inject('assignAgent');
|
||||
const assignTeam = inject('assignTeam');
|
||||
const assignLabels = inject('assignLabels');
|
||||
const updateConversationStatus = inject('updateConversationStatus');
|
||||
const toggleContextMenu = inject('toggleContextMenu');
|
||||
const markAsUnread = inject('markAsUnread');
|
||||
const markAsRead = inject('markAsRead');
|
||||
const assignPriority = inject('assignPriority');
|
||||
const isConversationSelected = inject('isConversationSelected');
|
||||
const deleteConversation = inject('deleteConversation');
|
||||
|
||||
const deleteConversationDialogRef = ref(null);
|
||||
|
||||
const { onAssignAgent, onAssignLabels } = useBulkActions();
|
||||
|
||||
async function assignTeam(team, conversationId = null) {
|
||||
try {
|
||||
await store.dispatch('assignTeam', {
|
||||
conversationId,
|
||||
teamId: team.id,
|
||||
});
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.SUCCESFUL', {
|
||||
team: team.name,
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.FAILED'));
|
||||
}
|
||||
}
|
||||
|
||||
function updateConversationStatus(conversationId, status, snoozedUntil) {
|
||||
store
|
||||
.dispatch('toggleStatus', {
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
});
|
||||
}
|
||||
|
||||
async function assignPriority(priority, conversationId = null) {
|
||||
store.dispatch('setCurrentChatPriority', {
|
||||
priority,
|
||||
conversationId,
|
||||
});
|
||||
store.dispatch('assignPriority', { conversationId, priority }).then(() => {
|
||||
useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
|
||||
newValue: priority,
|
||||
from: 'Context menu',
|
||||
});
|
||||
useAlert(
|
||||
t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
|
||||
priority,
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function redirectToConversationList() {
|
||||
const accountId = store.getters.getCurrentAccountId;
|
||||
const conversationType = props.conversationType || '';
|
||||
const inboxId = store.getters.getSelectedInbox?.id;
|
||||
|
||||
let path = `/app/accounts/${accountId}/conversations`;
|
||||
|
||||
if (conversationType === 'mention') {
|
||||
path = `/app/accounts/${accountId}/mentions/conversations`;
|
||||
} else if (conversationType === 'unattended') {
|
||||
path = `/app/accounts/${accountId}/unattended/conversations`;
|
||||
} else if (props.foldersId) {
|
||||
path = `/app/accounts/${accountId}/custom_view/${props.foldersId}`;
|
||||
} else if (inboxId) {
|
||||
path = `/app/accounts/${accountId}/inbox/${inboxId}`;
|
||||
} else if (props.teamId) {
|
||||
path = `/app/accounts/${accountId}/team/${props.teamId}`;
|
||||
} else if (props.label) {
|
||||
path = `/app/accounts/${accountId}/label/${props.label}`;
|
||||
}
|
||||
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
async function markAsUnread(conversationId) {
|
||||
try {
|
||||
await store.dispatch('markMessagesUnread', {
|
||||
id: conversationId,
|
||||
});
|
||||
redirectToConversationList();
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(conversationId) {
|
||||
try {
|
||||
await store.dispatch('markMessagesRead', {
|
||||
id: conversationId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
deleteConversationDialogRef.value.open();
|
||||
}
|
||||
|
||||
async function deleteConversation() {
|
||||
try {
|
||||
await store.dispatch('deleteConversation', props.source.id);
|
||||
redirectToConversationList();
|
||||
deleteConversationDialogRef.value.close();
|
||||
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -61,14 +176,27 @@ const deleteConversation = inject('deleteConversation');
|
||||
enable-context-menu
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
@assign-agent="assignAgent"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-team="assignTeam"
|
||||
@assign-label="assignLabels"
|
||||
@assign-label="onAssignLabels"
|
||||
@update-conversation-status="updateConversationStatus"
|
||||
@context-menu-toggle="toggleContextMenu"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
@delete-conversation="handleDelete"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
type="alert"
|
||||
:title="
|
||||
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
|
||||
conversationId: source.id,
|
||||
})
|
||||
"
|
||||
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
|
||||
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
|
||||
@confirm="deleteConversation"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useInfiniteScroll } from '@vueuse/core';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
@@ -76,7 +77,7 @@ useKeyboardEvents({
|
||||
<template>
|
||||
<div
|
||||
ref="parentRef"
|
||||
class="conversation-list flex-1 w-full h-full touch-pan-y overscroll-contain [-webkit-overflow-scrolling:touch] [contain:strict] px-2 pt-2.5"
|
||||
class="conversation-list flex-1 w-full h-full touch-pan-y overscroll-contain [-webkit-overflow-scrolling:touch] [contain:strict] px-2 pt-2"
|
||||
:class="isContextMenuOpen ? 'overflow-hidden' : 'overflow-y-auto'"
|
||||
>
|
||||
<Virtualizer
|
||||
@@ -104,9 +105,15 @@ useKeyboardEvents({
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
class="p-4 text-center text-sm text-n-slate-10 whitespace-nowrap"
|
||||
>
|
||||
{{ t('CHAT_LIST.EOF') }}
|
||||
<Icon
|
||||
icon="i-woot-party"
|
||||
class="size-4 inline-block align-middle ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
<span class="align-middle">
|
||||
{{ t('CHAT_LIST.EOF') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,19 +47,19 @@ const onTabClick = event => {
|
||||
class="flex-shrink-0 my-0 mx-2 ltr:first:ml-0 rtl:first:mr-0 ltr:last:mr-0 rtl:last:ml-0 hover:text-n-slate-12"
|
||||
>
|
||||
<a
|
||||
class="flex items-center flex-row border-b select-none cursor-pointer text-sm relative top-[1px] transition-[border-color] duration-[150ms] ease-[cubic-bezier(0.37,0,0.63,1)]"
|
||||
class="flex items-center flex-row select-none cursor-pointer text-sm relative after:absolute after:bottom-0 after:left-0 after:right-0 font-460 after:h-[2px] after:rounded-full after:transition-all after:duration-200"
|
||||
:class="[
|
||||
active
|
||||
? 'border-b border-n-brand text-n-blue-11'
|
||||
: 'border-transparent text-n-slate-11',
|
||||
isCompact ? 'py-2 text-sm' : 'text-base py-3',
|
||||
? 'text-n-blue-11 after:bg-n-brand after:opacity-100'
|
||||
: 'text-n-slate-11 after:bg-transparent after:opacity-0',
|
||||
isCompact ? 'py-2.5 text-sm' : 'text-base py-3',
|
||||
]"
|
||||
@click="onTabClick"
|
||||
>
|
||||
{{ name }}
|
||||
<div
|
||||
v-if="showBadge"
|
||||
class="rounded-full h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1.5 py-0 min-w-[20px]"
|
||||
class="rounded-full h-5 flex items-center justify-center text-xxs font-semibold my-0 ltr:ml-1 rtl:mr-1 px-1.5 py-0 min-w-[20px]"
|
||||
:class="[
|
||||
active
|
||||
? 'bg-n-blue-3 text-n-blue-11'
|
||||
|
||||
@@ -48,13 +48,13 @@ useKeyboardEvents(keyboardEvents);
|
||||
<template>
|
||||
<woot-tabs
|
||||
:index="activeTabIndex"
|
||||
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0"
|
||||
class="w-full px-4 py-0 [&_ul]:p-0 h-10"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
v-for="(item, index) in items"
|
||||
:key="item.key"
|
||||
class="text-sm [&_a]:font-medium"
|
||||
class="text-sm"
|
||||
:index="index"
|
||||
:name="item.name"
|
||||
:count="item.count"
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
|
||||
import ActiveFilterPreview from 'dashboard/components-next/filter/ActiveFilterPreview.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeFolder: { type: Object, default: null },
|
||||
hasActiveFolders: { type: Boolean, default: false },
|
||||
isOnExpandedLayout: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'clearFilters',
|
||||
'saveFolder',
|
||||
'deleteFolder',
|
||||
'openFilter',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
|
||||
|
||||
const activeFolderQuery = computed(() => {
|
||||
const query = props.activeFolder?.query?.payload;
|
||||
if (!Array.isArray(query)) return [];
|
||||
|
||||
const newFilters = query.map(filter => {
|
||||
const transformed = useCamelCase(filter);
|
||||
return {
|
||||
attributeKey: transformed.attributeKey,
|
||||
attributeModel: transformed.attributeModel,
|
||||
customAttributeType: transformed.customAttributeType,
|
||||
filterOperator: transformed.filterOperator,
|
||||
queryOperator: transformed.queryOperator ?? 'and',
|
||||
values: transformed.values,
|
||||
};
|
||||
});
|
||||
|
||||
return newFilters;
|
||||
});
|
||||
|
||||
const activeFilterQueryData = computed(() => {
|
||||
return props.hasActiveFolders
|
||||
? activeFolderQuery.value
|
||||
: appliedFilters.value;
|
||||
});
|
||||
|
||||
const showPreview = computed(() => {
|
||||
return activeFilterQueryData.value.length > 0;
|
||||
});
|
||||
|
||||
const containerRef = ref(null);
|
||||
const { width: containerWidth } = useElementSize(containerRef);
|
||||
|
||||
const maxVisibleFilters = computed(() => {
|
||||
const totalFilters = activeFilterQueryData.value.length;
|
||||
const width = containerWidth.value;
|
||||
|
||||
if (!width || totalFilters === 0) return 0;
|
||||
|
||||
const chipWidth = 200; // Estimated avg width + gap
|
||||
const buttonSpace = 400; // Reserved space for "Edit/Delete/More" buttons
|
||||
|
||||
// Calculate available space across 2 lines
|
||||
const totalAvailablePixels = Math.max(0, width * 2 - buttonSpace);
|
||||
const maxCapacity = Math.floor(totalAvailablePixels / chipWidth);
|
||||
// Return at least 1, but no more than actual total
|
||||
return Math.max(1, Math.min(maxCapacity, totalFilters));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showPreview"
|
||||
ref="containerRef"
|
||||
class="flex items-center justify-between gap-2 mx-2 px-2 pb-2 pt-0.5 border-b-[1.2px] border-dashed border-n-strong mt-px"
|
||||
>
|
||||
<ActiveFilterPreview
|
||||
:applied-filters="activeFilterQueryData"
|
||||
:max-visible-filters="maxVisibleFilters"
|
||||
:more-filters-label="
|
||||
t('FILTER.ACTIVE_FILTERS.MORE_FILTERS', {
|
||||
count: activeFilterQueryData.length - maxVisibleFilters,
|
||||
})
|
||||
"
|
||||
:clear-button-label="t('FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')"
|
||||
:show-clear-button="!hasActiveFolders"
|
||||
class="flex-1 min-w-0"
|
||||
@open-filter="emit('openFilter')"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
>
|
||||
<template #actions>
|
||||
<template v-if="hasActiveFolders">
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg" />
|
||||
<Button
|
||||
:label="t('FILTER.ACTIVE_FILTERS.EDIT_BUTTON')"
|
||||
link
|
||||
class="hover:!no-underline !text-n-blue-11 !px-0.5"
|
||||
xs
|
||||
@click="emit('openFilter')"
|
||||
/>
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg" />
|
||||
<div class="inline-flex items-center">
|
||||
<Button
|
||||
:label="t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
|
||||
link
|
||||
ruby
|
||||
class="hover:!no-underline !no-underline !px-0.5"
|
||||
xs
|
||||
@click="emit('deleteFolder')"
|
||||
/>
|
||||
<!-- Teleport target for Delete modal -->
|
||||
<div
|
||||
id="deleteFilterTeleportTarget"
|
||||
class="absolute z-50 mt-2"
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="appliedFilters.length > 0"
|
||||
class="w-px h-3 bg-n-weak rounded-lg"
|
||||
/>
|
||||
<div
|
||||
v-if="appliedFilters.length > 0"
|
||||
class="relative inline-flex items-center"
|
||||
>
|
||||
<Button
|
||||
:label="t('FILTER.CUSTOM_VIEWS.SAVE_VIEW')"
|
||||
link
|
||||
class="hover:!no-underline !text-n-blue-11 !px-0.5"
|
||||
xs
|
||||
@click="emit('saveFolder')"
|
||||
/>
|
||||
<!-- Teleport target for Save modal -->
|
||||
<div
|
||||
id="saveFilterTeleportTarget"
|
||||
class="absolute z-50 top-6"
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ActiveFilterPreview>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
+7
-3
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useTemplateRef, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
@@ -26,6 +26,7 @@ const { updateUISettings } = useUISettings();
|
||||
const chatStatusFilter = useMapGetter('getChatStatusFilter');
|
||||
const chatSortFilter = useMapGetter('getChatSortFilter');
|
||||
|
||||
const filterContainerRef = useTemplateRef('filterContainerRef');
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const currentStatusFilter = computed(() => {
|
||||
@@ -131,7 +132,7 @@ const handleSortChange = value => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex">
|
||||
<div ref="filterContainerRef" class="relative flex">
|
||||
<NextButton
|
||||
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
|
||||
icon="i-lucide-arrow-up-down"
|
||||
@@ -142,7 +143,10 @@ const handleSortChange = value => {
|
||||
/>
|
||||
<div
|
||||
v-if="showActionsDropdown"
|
||||
v-on-click-outside="() => toggleDropdown()"
|
||||
v-on-click-outside="[
|
||||
() => toggleDropdown(),
|
||||
{ ignore: [filterContainerRef] },
|
||||
]"
|
||||
class="mt-1 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 absolute z-40 top-full"
|
||||
:class="{
|
||||
'ltr:left-0 rtl:right-0': !isOnExpandedLayout,
|
||||
|
||||
-254
@@ -1,254 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Avatar,
|
||||
Spinner,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
emits: ['select', 'close'],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedAgent: null,
|
||||
goBackToAgentList: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'bulkActions/getUIFlags',
|
||||
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
}),
|
||||
filteredAgents() {
|
||||
if (this.query) {
|
||||
return this.assignableAgents.filter(agent =>
|
||||
agent.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
);
|
||||
}
|
||||
return [
|
||||
{
|
||||
confirmed: true,
|
||||
name: 'None',
|
||||
id: null,
|
||||
role: 'agent',
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
},
|
||||
...this.assignableAgents,
|
||||
];
|
||||
},
|
||||
assignableAgents() {
|
||||
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
|
||||
this.selectedInboxes.join(',')
|
||||
);
|
||||
},
|
||||
conversationLabel() {
|
||||
return this.conversationCount > 1 ? 'conversations' : 'conversation';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', this.selectedInboxes);
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.$emit('select', this.selectedAgent);
|
||||
},
|
||||
goBack() {
|
||||
this.goBackToAgentList = true;
|
||||
this.selectedAgent = null;
|
||||
},
|
||||
assignAgent(agent) {
|
||||
this.selectedAgent = agent;
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
onCloseAgentList() {
|
||||
if (this.selectedAgent === null && !this.goBackToAgentList) {
|
||||
this.onClose();
|
||||
}
|
||||
this.goBackToAgentList = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between header">
|
||||
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="container">
|
||||
<div
|
||||
v-if="assignableAgentsUiFlags.isFetching"
|
||||
class="agent__list-loading"
|
||||
>
|
||||
<Spinner />
|
||||
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
|
||||
</div>
|
||||
<div v-else class="agent__list-container">
|
||||
<ul v-if="!selectedAgent">
|
||||
<li class="search-container">
|
||||
<div
|
||||
class="flex items-center justify-between h-8 gap-2 agent-list-search"
|
||||
>
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="reset-base !outline-0 !text-sm agent--search_input"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li v-for="agent in filteredAgents" :key="agent.id">
|
||||
<div class="agent-list-item" @click="assignAgent(agent)">
|
||||
<Avatar
|
||||
:name="agent.name"
|
||||
:src="agent.thumbnail"
|
||||
:status="agent.availability_status"
|
||||
:size="22"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<span class="my-0 text-n-slate-12">
|
||||
{{ agent.name }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="agent-confirmation-container">
|
||||
<p v-if="selectedAgent.id">
|
||||
{{
|
||||
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
|
||||
conversationCount,
|
||||
conversationLabel,
|
||||
})
|
||||
}}
|
||||
<strong>
|
||||
{{ selectedAgent.name }}
|
||||
</strong>
|
||||
<span>?</span>
|
||||
</p>
|
||||
<p v-else>
|
||||
{{
|
||||
$t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
|
||||
conversationCount,
|
||||
conversationLabel,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="agent-confirmation-actions">
|
||||
<NextButton
|
||||
faded
|
||||
sm
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('BULK_ACTION.GO_BACK_LABEL')"
|
||||
@click="goBack"
|
||||
/>
|
||||
<NextButton
|
||||
sm
|
||||
type="submit"
|
||||
:label="$t('BULK_ACTION.YES')"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__agents {
|
||||
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
|
||||
.header {
|
||||
@apply p-2.5;
|
||||
|
||||
span {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply overflow-y-auto max-h-[15rem];
|
||||
.agent__list-container {
|
||||
@apply h-full;
|
||||
}
|
||||
.agent-list-search {
|
||||
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
|
||||
.search-icon {
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
|
||||
.agent--search_input {
|
||||
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
|
||||
}
|
||||
}
|
||||
}
|
||||
.triangle {
|
||||
@apply block z-10 absolute -top-3 text-left ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
}
|
||||
}
|
||||
ul {
|
||||
@apply m-0 list-none;
|
||||
|
||||
li {
|
||||
&:last-child {
|
||||
.agent-list-item {
|
||||
@apply last:rounded-b-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agent-list-item {
|
||||
@apply flex items-center p-2.5 gap-2 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
|
||||
span {
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-confirmation-container {
|
||||
@apply flex flex-col h-full p-2.5;
|
||||
p {
|
||||
@apply flex-grow;
|
||||
}
|
||||
.agent-confirmation-actions {
|
||||
@apply w-full grid grid-cols-2 gap-2.5;
|
||||
}
|
||||
}
|
||||
.search-container {
|
||||
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
|
||||
}
|
||||
|
||||
.agent__list-loading {
|
||||
@apply m-2.5 rounded-md dark:bg-n-solid-3 bg-n-slate-2 flex items-center justify-center flex-col p-5 h-[calc(95%-6.25rem)];
|
||||
}
|
||||
</style>
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, computed, ref } from 'vue';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedAgent = ref(null);
|
||||
|
||||
const assignableAgentsUiFlags = computed(
|
||||
() => store.getters['inboxAssignableAgents/getUIFlags']
|
||||
);
|
||||
|
||||
const isLoading = computed(() => assignableAgentsUiFlags.value.isFetching);
|
||||
|
||||
const assignableAgentsList = useMapGetter(
|
||||
'inboxAssignableAgents/getAssignableAgents'
|
||||
);
|
||||
const assignableAgents = computed(() =>
|
||||
assignableAgentsList.value(props.selectedInboxes.join(','))
|
||||
);
|
||||
|
||||
const agentMenuItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
action: 'select',
|
||||
value: 'none',
|
||||
label: 'None',
|
||||
thumbnail: {
|
||||
name: 'None',
|
||||
src: '',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
assignableAgents.value.forEach(agent => {
|
||||
items.push({
|
||||
action: 'select',
|
||||
value: agent.id,
|
||||
label: agent.name,
|
||||
thumbnail: {
|
||||
name: agent.name,
|
||||
src: agent.thumbnail,
|
||||
},
|
||||
isSelected: selectedAgent.value?.id === agent.id,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleSelectAgent = item => {
|
||||
if (item.value === 'none') {
|
||||
selectedAgent.value = { id: null, name: 'None' };
|
||||
} else {
|
||||
const agent = assignableAgents.value.find(a => a.id === item.value);
|
||||
selectedAgent.value = agent || { id: null, name: 'None' };
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
emit('select', selectedAgent.value);
|
||||
selectedAgent.value = null;
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
selectedAgent.value = null;
|
||||
};
|
||||
|
||||
const handleToggleDropdown = () => {
|
||||
const willOpen = !showDropdown.value;
|
||||
toggleDropdown();
|
||||
|
||||
// Fetch agents only when opening the dropdown
|
||||
if (willOpen && props.selectedInboxes.length > 0) {
|
||||
store.dispatch('inboxAssignableAgents/fetch', props.selectedInboxes);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<Button
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
|
||||
icon="i-lucide-user-round-check"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
:class="{ 'bg-n-alpha-2': showDropdown }"
|
||||
@click="handleToggleDropdown"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-top"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-top"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-click-outside="[
|
||||
() => toggleDropdown(false),
|
||||
{ ignore: [containerRef] },
|
||||
]"
|
||||
:menu-items="agentMenuItems"
|
||||
:is-loading="isLoading"
|
||||
show-search
|
||||
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="ltr:-right-10 rtl:-left-10 ltr:2xl:right-0 rtl:2xl:left-0 top-8 w-60 max-h-80 overflow-y-auto"
|
||||
@action="handleSelectAgent"
|
||||
>
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="selectedAgent"
|
||||
class="pt-2 pb-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px] after:absolute after:-bottom-2 after:left-0 after:w-full after:h-2 after:bg-n-alpha-3 after:backdrop-blur-[4px]"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<I18nT
|
||||
v-if="selectedAgent.id"
|
||||
keypath="BULK_ACTION.ASSIGN_CONFIRMATION_LABEL"
|
||||
tag="p"
|
||||
class="text-xs text-n-slate-11 px-1 mb-0"
|
||||
:plural="props.conversationCount"
|
||||
>
|
||||
<template #conversationCount>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ props.conversationCount }}
|
||||
</strong>
|
||||
</template>
|
||||
<template #agentName>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ selectedAgent.name }}
|
||||
</strong>
|
||||
</template>
|
||||
</I18nT>
|
||||
<I18nT
|
||||
v-else
|
||||
keypath="BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL"
|
||||
tag="p"
|
||||
class="text-xs text-n-slate-11 px-1 mb-0"
|
||||
:plural="props.conversationCount"
|
||||
>
|
||||
<template #n>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ props.conversationCount }}
|
||||
</strong>
|
||||
</template>
|
||||
</I18nT>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
class="flex-1"
|
||||
:label="t('BULK_ACTION.CANCEL')"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
class="flex-1"
|
||||
:label="t('BULK_ACTION.YES')"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<script setup>
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'conversation',
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['assign']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedLabels = ref([]);
|
||||
|
||||
const isTypeContact = computed(() => props.type === 'contact');
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (props.type === 'contact') return t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
|
||||
return '';
|
||||
});
|
||||
|
||||
const isLabelSelected = labelTitle => {
|
||||
return selectedLabels.value.includes(labelTitle);
|
||||
};
|
||||
|
||||
const labelMenuItems = computed(() => {
|
||||
return labels.value.map(label => ({
|
||||
action: 'select',
|
||||
value: label.title,
|
||||
label: label.title,
|
||||
color: label.color,
|
||||
id: label.id,
|
||||
isSelected: isLabelSelected(label.title),
|
||||
}));
|
||||
});
|
||||
|
||||
const toggleLabelSelection = labelTitle => {
|
||||
const index = selectedLabels.value.indexOf(labelTitle);
|
||||
if (index > -1) {
|
||||
selectedLabels.value.splice(index, 1);
|
||||
} else {
|
||||
selectedLabels.value.push(labelTitle);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedLabels.value.length > 0) {
|
||||
emit('assign', selectedLabels.value);
|
||||
toggleDropdown(false);
|
||||
selectedLabels.value = [];
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<NextButton
|
||||
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-tag"
|
||||
slate
|
||||
:size="isTypeContact ? 'sm' : 'xs'"
|
||||
ghost
|
||||
:class="{
|
||||
'bg-n-alpha-2': showDropdown,
|
||||
'[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit':
|
||||
isTypeContact,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:is-loading="isLoading"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-top"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-top"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-click-outside="[
|
||||
() => toggleDropdown(false),
|
||||
{ ignore: [containerRef] },
|
||||
]"
|
||||
:menu-items="labelMenuItems"
|
||||
show-search
|
||||
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="top-8 w-60 max-h-80 overflow-y-auto"
|
||||
:class="{
|
||||
'ltr:-right-[6.5rem] rtl:-left-[6.5rem] ltr:2xl:right-0 rtl:2xl:left-0':
|
||||
!isTypeContact,
|
||||
'ltr:right-0 rtl:left-0 mt-1': isTypeContact,
|
||||
}"
|
||||
@action="item => toggleLabelSelection(item.value)"
|
||||
>
|
||||
<template #thumbnail="{ item }">
|
||||
<span
|
||||
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #trailing-icon="{ item }">
|
||||
<Icon
|
||||
v-if="isLabelSelected(item.value)"
|
||||
icon="i-lucide-check"
|
||||
class="size-4 text-n-blue-11 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div
|
||||
class="sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px] after:absolute after:-bottom-2 after:left-0 after:w-full after:h-2 after:bg-n-alpha-3 after:backdrop-blur-[4px]"
|
||||
>
|
||||
<NextButton
|
||||
sm
|
||||
class="w-full"
|
||||
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, computed, ref, onMounted } from 'vue';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedTeam = ref(null);
|
||||
|
||||
const teams = useMapGetter('teams/getTeams');
|
||||
|
||||
const teamMenuItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
action: 'select',
|
||||
value: 'none',
|
||||
label: t('BULK_ACTION.TEAMS.NONE'),
|
||||
},
|
||||
];
|
||||
|
||||
teams.value.forEach(team => {
|
||||
items.push({
|
||||
action: 'select',
|
||||
value: team.id,
|
||||
label: team.name,
|
||||
isSelected: selectedTeam.value?.id === team.id,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleSelectTeam = item => {
|
||||
if (item.value === 'none') {
|
||||
selectedTeam.value = { id: 0, name: 'None' };
|
||||
} else {
|
||||
const foundTeam = teams.value.find(team => team.id === item.value);
|
||||
selectedTeam.value = foundTeam || { id: 0, name: 'None' };
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
emit('select', selectedTeam.value);
|
||||
selectedTeam.value = null;
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
selectedTeam.value = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('teams/fetch');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<Button
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
|
||||
icon="i-lucide-users-round"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
:class="{ 'bg-n-alpha-2': showDropdown }"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-top"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-top"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-click-outside="[
|
||||
() => toggleDropdown(false),
|
||||
{ ignore: [containerRef] },
|
||||
]"
|
||||
:menu-items="teamMenuItems"
|
||||
show-search
|
||||
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="ltr:-right-2 rtl:-left-2 top-8 w-60 max-h-80 overflow-y-auto"
|
||||
@action="handleSelectTeam"
|
||||
>
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="selectedTeam"
|
||||
class="pt-2 pb-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px] after:absolute after:-bottom-2 after:left-0 after:w-full after:h-2 after:bg-n-alpha-3 after:backdrop-blur-[4px]"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<I18nT
|
||||
v-if="selectedTeam.id"
|
||||
keypath="BULK_ACTION.TEAMS.ASSIGN_CONFIRMATION_LABEL"
|
||||
tag="p"
|
||||
class="text-xs text-n-slate-11 px-1 mb-0"
|
||||
:plural="props.conversationCount"
|
||||
>
|
||||
<template #conversationCount>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ props.conversationCount }}
|
||||
</strong>
|
||||
</template>
|
||||
<template #teamName>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ selectedTeam.name }}
|
||||
</strong>
|
||||
</template>
|
||||
</I18nT>
|
||||
<I18nT
|
||||
v-else
|
||||
keypath="BULK_ACTION.TEAMS.UNASSIGN_CONFIRMATION_LABEL"
|
||||
tag="p"
|
||||
class="text-xs text-n-slate-11 px-1 mb-0"
|
||||
:plural="props.conversationCount"
|
||||
>
|
||||
<template #n>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ props.conversationCount }}
|
||||
</strong>
|
||||
</template>
|
||||
</I18nT>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
class="flex-1"
|
||||
:label="t('BULK_ACTION.CANCEL')"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
class="flex-1"
|
||||
:label="t('BULK_ACTION.YES')"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
showResolve: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showReopen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSnooze: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
|
||||
const updateMenuItems = computed(() => {
|
||||
const items = [];
|
||||
|
||||
if (props.showResolve) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
value: 'resolved',
|
||||
label: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
|
||||
icon: 'i-lucide-check',
|
||||
});
|
||||
}
|
||||
|
||||
if (props.showReopen) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
value: 'open',
|
||||
label: t('CONVERSATION.HEADER.REOPEN_ACTION'),
|
||||
icon: 'i-lucide-redo',
|
||||
});
|
||||
}
|
||||
|
||||
if (props.showSnooze) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
value: 'snoozed',
|
||||
label: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
|
||||
icon: 'i-lucide-alarm-clock',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleUpdate = item => {
|
||||
if (item.value === 'snoozed') {
|
||||
// If the user clicks on the snooze option from the bulk action change status dropdown.
|
||||
// Open the snooze option for bulk action in the cmd bar.
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
|
||||
} else {
|
||||
emit('update', item.value);
|
||||
}
|
||||
toggleDropdown(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<Button
|
||||
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
|
||||
icon="i-lucide-circle-fading-arrow-up"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
:class="{ 'bg-n-alpha-2': showDropdown }"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-top"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-top"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-click-outside="[
|
||||
() => toggleDropdown(false),
|
||||
{ ignore: [containerRef] },
|
||||
]"
|
||||
:menu-items="updateMenuItems"
|
||||
class="ltr:-right-[4.5rem] rtl:-left-[4.5rem] ltr:2xl:right-0 rtl:2xl:left-0 top-8 w-36"
|
||||
@action="handleUpdate"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+125
-276
@@ -1,7 +1,9 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions.js';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
@@ -10,243 +12,150 @@ import {
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import AgentSelector from './AgentSelector.vue';
|
||||
import UpdateActions from './UpdateActions.vue';
|
||||
import LabelActions from './LabelActions.vue';
|
||||
import TeamActions from './TeamActions.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import BulkAgentActions from './BulkAgentActions.vue';
|
||||
import BulkUpdateActions from './BulkUpdateActions.vue';
|
||||
import BulkLabelActions from './BulkLabelActions.vue';
|
||||
import BulkTeamActions from './BulkTeamActions.vue';
|
||||
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
|
||||
export default {
|
||||
components: {
|
||||
AgentSelector,
|
||||
UpdateActions,
|
||||
LabelActions,
|
||||
TeamActions,
|
||||
CustomSnoozeModal,
|
||||
NextButton,
|
||||
|
||||
const props = defineProps({
|
||||
conversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
props: {
|
||||
conversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
allConversationsSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showOpenAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showResolvedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSnoozedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allConversationsSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emits: [
|
||||
'selectAllConversations',
|
||||
'assignAgent',
|
||||
'updateConversations',
|
||||
'assignLabels',
|
||||
'assignTeam',
|
||||
'resolveConversations',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
showAgentsList: false,
|
||||
showUpdateActions: false,
|
||||
showLabelActions: false,
|
||||
showTeamsList: false,
|
||||
popoverPositions: {},
|
||||
showCustomTimeSnoozeModal: false,
|
||||
};
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
mounted() {
|
||||
emitter.on(
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
this.onCmdSnoozeConversation
|
||||
);
|
||||
emitter.on(
|
||||
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||
this.onCmdReopenConversation
|
||||
);
|
||||
emitter.on(
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
this.onCmdResolveConversation
|
||||
);
|
||||
showOpenAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
unmounted() {
|
||||
emitter.off(
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
this.onCmdSnoozeConversation
|
||||
);
|
||||
emitter.off(
|
||||
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||
this.onCmdReopenConversation
|
||||
);
|
||||
emitter.off(
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
this.onCmdResolveConversation
|
||||
);
|
||||
showResolvedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
methods: {
|
||||
onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomTimeSnoozeModal = true;
|
||||
} else {
|
||||
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
|
||||
}
|
||||
},
|
||||
onCmdReopenConversation() {
|
||||
this.updateConversations('open', null);
|
||||
},
|
||||
onCmdResolveConversation() {
|
||||
this.updateConversations('resolved', null);
|
||||
},
|
||||
customSnoozeTime(customSnoozedTime) {
|
||||
this.showCustomTimeSnoozeModal = false;
|
||||
if (customSnoozedTime) {
|
||||
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
|
||||
}
|
||||
},
|
||||
hideCustomSnoozeModal() {
|
||||
this.showCustomTimeSnoozeModal = false;
|
||||
},
|
||||
selectAll(e) {
|
||||
this.$emit('selectAllConversations', e.target.checked);
|
||||
},
|
||||
submit(agent) {
|
||||
this.$emit('assignAgent', agent);
|
||||
},
|
||||
updateConversations(status, snoozedUntil) {
|
||||
this.$emit('updateConversations', status, snoozedUntil);
|
||||
},
|
||||
assignLabels(labels) {
|
||||
this.$emit('assignLabels', labels);
|
||||
},
|
||||
assignTeam(team) {
|
||||
this.$emit('assignTeam', team);
|
||||
},
|
||||
resolveConversations() {
|
||||
this.$emit('resolveConversations');
|
||||
},
|
||||
toggleUpdateActions() {
|
||||
this.showUpdateActions = !this.showUpdateActions;
|
||||
},
|
||||
toggleLabelActions() {
|
||||
this.showLabelActions = !this.showLabelActions;
|
||||
},
|
||||
toggleAgentList() {
|
||||
this.showAgentsList = !this.showAgentsList;
|
||||
},
|
||||
toggleTeamsList() {
|
||||
this.showTeamsList = !this.showTeamsList;
|
||||
},
|
||||
showSnoozedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectAllConversations']);
|
||||
|
||||
const {
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onAssignTeamsForBulk: onAssignTeam,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
|
||||
const showCustomTimeSnoozeModal = ref(false);
|
||||
|
||||
function onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
showCustomTimeSnoozeModal.value = true;
|
||||
} else {
|
||||
onUpdateConversations('snoozed', findSnoozeTime(snoozeType) || null);
|
||||
}
|
||||
}
|
||||
|
||||
function onCmdReopenConversation() {
|
||||
onUpdateConversations('open', null);
|
||||
}
|
||||
|
||||
function onCmdResolveConversation() {
|
||||
onUpdateConversations('resolved', null);
|
||||
}
|
||||
|
||||
function customSnoozeTime(customSnoozedTime) {
|
||||
showCustomTimeSnoozeModal.value = false;
|
||||
if (customSnoozedTime) {
|
||||
onUpdateConversations('snoozed', getUnixTime(customSnoozedTime));
|
||||
}
|
||||
}
|
||||
|
||||
function hideCustomSnoozeModal() {
|
||||
showCustomTimeSnoozeModal.value = false;
|
||||
}
|
||||
|
||||
// Computed property with getter/setter to enable v-model usage
|
||||
const allSelected = computed({
|
||||
get: () => props.allConversationsSelected,
|
||||
set: value => {
|
||||
emit('selectAllConversations', value);
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
|
||||
emitter.on(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
|
||||
emitter.on(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
|
||||
emitter.off(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
|
||||
emitter.off(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bulk-action__container">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center justify-between bulk-action__panel">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:checked="allConversationsSelected"
|
||||
:indeterminate.prop="!allConversationsSelected"
|
||||
@change="selectAll($event)"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
|
||||
conversationCount: conversations.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-1 bulk-action__actions">
|
||||
<div class="pt-3 pb-2 px-2 relative">
|
||||
<div
|
||||
class="flex items-center justify-between p-2 bg-n-button-color outline outline-1 -outline-offset-1 rounded-[10px] outline-n-weak"
|
||||
>
|
||||
<div class="ltr:ml-0.5 rtl:mr-0.5 flex items-center gap-1">
|
||||
<label class="cursor-pointer flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="allSelected"
|
||||
:indeterminate="!allConversationsSelected"
|
||||
/>
|
||||
<span class="cursor-pointer">
|
||||
{{
|
||||
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
|
||||
conversationCount: conversations.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg ltr:ml-1 rtl:mr-1" />
|
||||
<NextButton
|
||||
v-tooltip="$t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
icon="i-lucide-tags"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="toggleLabelActions"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
|
||||
icon="i-lucide-repeat"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="toggleUpdateActions"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
|
||||
icon="i-lucide-user-round-plus"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="toggleAgentList"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
|
||||
icon="i-lucide-users-round"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="toggleTeamsList"
|
||||
:label="$t('BULK_ACTION.CLEAR_SELECTION')"
|
||||
ghost
|
||||
class="!text-n-blue-11 !px-1 !h-6"
|
||||
sm
|
||||
@click="allSelected = false"
|
||||
/>
|
||||
</div>
|
||||
<transition name="popover-animation">
|
||||
<LabelActions
|
||||
v-if="showLabelActions"
|
||||
class="label-actions-box"
|
||||
@assign="assignLabels"
|
||||
@close="showLabelActions = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<UpdateActions
|
||||
v-if="showUpdateActions"
|
||||
class="update-actions-box"
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
<div class="flex items-center gap-2">
|
||||
<BulkLabelActions @assign="onAssignLabels" />
|
||||
<BulkUpdateActions
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
:show-snooze="!showSnoozedAction"
|
||||
@update="updateConversations"
|
||||
@close="showUpdateActions = false"
|
||||
@update="onUpdateConversations"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<AgentSelector
|
||||
v-if="showAgentsList"
|
||||
class="agent-actions-box"
|
||||
<BulkAgentActions
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
@select="submit"
|
||||
@close="showAgentsList = false"
|
||||
@select="onAssignAgent"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<TeamActions
|
||||
v-if="showTeamsList"
|
||||
class="team-actions-box"
|
||||
@assign-team="assignTeam"
|
||||
@close="showTeamsList = false"
|
||||
<BulkTeamActions
|
||||
:conversation-count="conversations.length"
|
||||
@select="onAssignTeam"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="allConversationsSelected" class="bulk-action__alert">
|
||||
<div
|
||||
v-if="allConversationsSelected"
|
||||
class="bg-n-amber-2 outline -outline-offset-1 outline-1 outline-n-amber-5 rounded-lg text-sm mt-2 py-1.5 px-2 text-n-amber-text"
|
||||
>
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
<woot-modal
|
||||
@@ -260,63 +169,3 @@ export default {
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__container {
|
||||
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
|
||||
}
|
||||
|
||||
.bulk-action__panel {
|
||||
@apply cursor-pointer;
|
||||
|
||||
span {
|
||||
@apply text-xs my-0 mx-1;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
@apply cursor-pointer m-0;
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-action__alert {
|
||||
@apply bg-n-amber-3 text-n-amber-12 rounded text-xs mt-2 py-1 px-2 border border-solid border-n-amber-5;
|
||||
}
|
||||
|
||||
.popover-animation-enter-active,
|
||||
.popover-animation-leave-active {
|
||||
transition: transform ease-out 0.1s;
|
||||
}
|
||||
|
||||
.popover-animation-enter {
|
||||
transform: scale(0.95);
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.popover-animation-enter-to {
|
||||
transform: scale(1);
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.popover-animation-leave {
|
||||
transform: scale(1);
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.popover-animation-leave-to {
|
||||
transform: scale(0.95);
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.label-actions-box {
|
||||
--triangle-position: 5.3125rem;
|
||||
}
|
||||
.update-actions-box {
|
||||
--triangle-position: 3.5rem;
|
||||
}
|
||||
.agent-actions-box {
|
||||
--triangle-position: 1.75rem;
|
||||
}
|
||||
.team-actions-box {
|
||||
--triangle-position: 0.125rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'assign']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const query = ref('');
|
||||
const selectedLabels = ref([]);
|
||||
|
||||
const filteredLabels = computed(() => {
|
||||
if (!query.value) return labels.value;
|
||||
return labels.value.filter(label =>
|
||||
label.title.toLowerCase().includes(query.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const hasLabels = computed(() => labels.value.length > 0);
|
||||
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
|
||||
|
||||
const isLabelSelected = label => {
|
||||
return selectedLabels.value.includes(label);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedLabels.value.length > 0) {
|
||||
emit('assign', selectedLabels.value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="onClose"
|
||||
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
|
||||
role="dialog"
|
||||
aria-labelledby="label-dialog-title"
|
||||
>
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2.5">
|
||||
<span class="text-sm font-medium">{{
|
||||
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
|
||||
}}</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="flex flex-col max-h-60 min-h-0">
|
||||
<header class="py-2 px-2.5">
|
||||
<Input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
icon-left="i-lucide-search"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</header>
|
||||
<ul
|
||||
v-if="hasLabels"
|
||||
class="flex-1 overflow-y-auto m-0 list-none"
|
||||
role="listbox"
|
||||
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
>
|
||||
<li v-if="!hasFilteredLabels" class="p-2 text-center">
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
|
||||
}}</span>
|
||||
</li>
|
||||
<li
|
||||
v-for="label in filteredLabels"
|
||||
:key="label.id"
|
||||
class="my-1 mx-0 py-0 px-2.5"
|
||||
role="option"
|
||||
:aria-selected="isLabelSelected(label.title)"
|
||||
>
|
||||
<label
|
||||
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
|
||||
>
|
||||
<input
|
||||
v-model="selectedLabels"
|
||||
type="checkbox"
|
||||
:value="label.title"
|
||||
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
|
||||
:aria-label="label.title"
|
||||
/>
|
||||
<span
|
||||
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ label.title }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
|
||||
:style="{ backgroundColor: label.color }"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="p-2 text-center">
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
|
||||
}}</span>
|
||||
</div>
|
||||
<footer class="p-2">
|
||||
<NextButton
|
||||
sm
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.triangle {
|
||||
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
emits: ['assignTeam', 'close'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedteams: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ teams: 'teams/getTeams' }),
|
||||
filteredTeams() {
|
||||
return [
|
||||
{ name: 'None', id: 0 },
|
||||
...this.teams.filter(team =>
|
||||
team.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
),
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
assignTeam(key) {
|
||||
this.$emit('assignTeam', key);
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="onClose" class="bulk-action__teams">
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between header">
|
||||
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="team__list-container">
|
||||
<ul>
|
||||
<li class="search-container">
|
||||
<div
|
||||
class="flex items-center justify-between h-8 gap-2 agent-list-search"
|
||||
>
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="reset-base !outline-0 !text-sm agent--search_input"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<template v-if="filteredTeams.length">
|
||||
<li v-for="team in filteredTeams" :key="team.id">
|
||||
<div class="team__list-item" @click="assignTeam(team)">
|
||||
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
|
||||
{{ team.name }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<li v-else>
|
||||
<div class="team__list-item">
|
||||
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
|
||||
{{ $t('BULK_ACTION.TEAMS.NO_TEAMS_AVAILABLE') }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__teams {
|
||||
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
|
||||
.header {
|
||||
@apply p-2.5;
|
||||
|
||||
span {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply overflow-y-auto max-h-[15rem];
|
||||
.team__list-container {
|
||||
@apply h-full;
|
||||
}
|
||||
.agent-list-search {
|
||||
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
|
||||
.search-icon {
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
|
||||
.agent--search_input {
|
||||
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent w-full h-[unset];
|
||||
}
|
||||
}
|
||||
}
|
||||
.triangle {
|
||||
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
}
|
||||
}
|
||||
ul {
|
||||
@apply m-0 list-none;
|
||||
|
||||
li {
|
||||
&:last-child {
|
||||
.agent-list-item {
|
||||
@apply last:rounded-b-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.team__list-item {
|
||||
@apply flex items-center p-2.5 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
|
||||
span {
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
|
||||
}
|
||||
</style>
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
showResolve: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showReopen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSnooze: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const actions = ref([
|
||||
{ icon: 'i-lucide-check', key: 'resolved' },
|
||||
{ icon: 'i-lucide-redo', key: 'open' },
|
||||
{ icon: 'i-lucide-alarm-clock', key: 'snoozed' },
|
||||
]);
|
||||
|
||||
const updateConversations = key => {
|
||||
if (key === 'snoozed') {
|
||||
// If the user clicks on the snooze option from the bulk action change status dropdown.
|
||||
// Open the snooze option for bulk action in the cmd bar.
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
|
||||
} else {
|
||||
emit('update', key);
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const showAction = key => {
|
||||
const actionsMap = {
|
||||
resolved: props.showResolve,
|
||||
open: props.showReopen,
|
||||
snoozed: props.showSnooze,
|
||||
};
|
||||
return actionsMap[key] || false;
|
||||
};
|
||||
|
||||
const actionLabel = key => {
|
||||
const labelsMap = {
|
||||
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
|
||||
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
|
||||
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
|
||||
};
|
||||
return labelsMap[key] || '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-clickaway="onClose"
|
||||
class="absolute z-20 w-auto origin-top-right border border-solid rounded-lg shadow-md ltr:right-2 rtl:left-2 top-12 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak"
|
||||
>
|
||||
<div
|
||||
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
|
||||
>
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path
|
||||
d="M20 12l-8-8-12 12"
|
||||
fill-rule="evenodd"
|
||||
stroke-width="1px"
|
||||
class="fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="p-2.5 flex gap-1 items-center justify-between">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
|
||||
</span>
|
||||
<Button ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="px-2.5 pt-0 pb-2.5">
|
||||
<WootDropdownMenu class="m-0 list-none">
|
||||
<template v-for="action in actions">
|
||||
<WootDropdownItem v-if="showAction(action.key)" :key="action.key">
|
||||
<Button
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
class="!w-full !justify-start"
|
||||
:icon="action.icon"
|
||||
:label="actionLabel(action.key)"
|
||||
@click="updateConversations(action.key)"
|
||||
/>
|
||||
</WootDropdownItem>
|
||||
</template>
|
||||
</WootDropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,9 +20,15 @@ export function useBulkActions() {
|
||||
|
||||
function deSelectConversation(conversationId, inboxId) {
|
||||
store.dispatch('bulkActions/removeSelectedConversationIds', conversationId);
|
||||
selectedInboxes.value = selectedInboxes.value.filter(
|
||||
item => item !== inboxId
|
||||
);
|
||||
// Only remove one instance of the inboxId, not all
|
||||
// This handles the case where multiple conversations from the same inbox are selected
|
||||
const index = selectedInboxes.value.indexOf(inboxId);
|
||||
if (index > -1) {
|
||||
selectedInboxes.value = [
|
||||
...selectedInboxes.value.slice(0, index),
|
||||
...selectedInboxes.value.slice(index + 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function resetBulkActions() {
|
||||
|
||||
@@ -75,6 +75,11 @@
|
||||
"ADDITIONAL_FILTERS": "Additional filters",
|
||||
"CUSTOM_ATTRIBUTES": "Custom attributes"
|
||||
},
|
||||
"ACTIVE_FILTERS": {
|
||||
"MORE_FILTERS": "+ {count} more filters",
|
||||
"CLEAR_FILTERS": "Clear filters",
|
||||
"EDIT_BUTTON": "Edit"
|
||||
},
|
||||
"CUSTOM_VIEWS": {
|
||||
"ADD": {
|
||||
"TITLE": "Do you want to save this filter?",
|
||||
@@ -95,8 +100,9 @@
|
||||
"EDIT": {
|
||||
"EDIT_BUTTON": "Edit folder"
|
||||
},
|
||||
"SAVE_VIEW": "Save View",
|
||||
"DELETE": {
|
||||
"DELETE_BUTTON": "Delete filter",
|
||||
"DELETE_BUTTON": "Delete",
|
||||
"MODAL": {
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirm deletion",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"BULK_ACTION": {
|
||||
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
|
||||
"CONVERSATIONS_SELECTED": "{conversationCount} selected",
|
||||
"AGENT_SELECT_LABEL": "Select agent",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
|
||||
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
|
||||
"CLEAR_SELECTION": "Clear",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {n} conversation to {agentName}? | Are you sure to assign {n} conversations to {agentName}?",
|
||||
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {n} conversation? | Are you sure to unassign {n} conversations?",
|
||||
"GO_BACK_LABEL": "Go back",
|
||||
"ASSIGN_LABEL": "Assign",
|
||||
"YES": "Yes",
|
||||
"CANCEL": "Cancel",
|
||||
"SEARCH_INPUT_PLACEHOLDER": "Search",
|
||||
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
|
||||
"ASSIGN_TEAM_TOOLTIP": "Assign team",
|
||||
@@ -34,6 +36,8 @@
|
||||
"NONE": "None",
|
||||
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
|
||||
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {n} conversation to {teamName}? | Are you sure to assign {n} conversations to {teamName}?",
|
||||
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {n} conversation? | Are you sure to unassign {n} conversations?",
|
||||
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign team. Please try again."
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"CHAT_LIST": {
|
||||
"LOADING": "Fetching conversations",
|
||||
"LOAD_MORE_CONVERSATIONS": "Load more conversations",
|
||||
"EOF": "All conversations loaded 🎉",
|
||||
"EOF": "All conversations loaded",
|
||||
"LIST": {
|
||||
"404": "There are no active conversations in this group."
|
||||
},
|
||||
|
||||
+11
-48
@@ -1,11 +1,10 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
|
||||
import BulkLabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -34,7 +33,6 @@ const { t } = useI18n();
|
||||
|
||||
const selectedCount = computed(() => props.selectedContactIds.length);
|
||||
const totalVisibleContacts = computed(() => props.visibleContactIds.length);
|
||||
const showLabelSelector = ref(false);
|
||||
|
||||
const selectAllLabel = computed(() => {
|
||||
if (!totalVisibleContacts.value) {
|
||||
@@ -73,28 +71,17 @@ const selectionModel = computed({
|
||||
});
|
||||
|
||||
const emitClearSelection = () => {
|
||||
showLabelSelector.value = false;
|
||||
emit('clearSelection');
|
||||
};
|
||||
|
||||
const toggleLabelSelector = () => {
|
||||
if (!selectedCount.value || props.isLoading) return;
|
||||
showLabelSelector.value = !showLabelSelector.value;
|
||||
};
|
||||
|
||||
const closeLabelSelector = () => {
|
||||
showLabelSelector.value = false;
|
||||
};
|
||||
|
||||
const handleAssignLabels = labels => {
|
||||
emit('assignLabels', labels);
|
||||
closeLabelSelector();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 z-10 bg-gradient-to-b from-n-background from-90% to-transparent px-6 pt-1 pb-2"
|
||||
class="sticky top-0 z-10 bg-gradient-to-b from-n-surface-1 from-90% to-transparent px-6 pt-1 pb-2"
|
||||
>
|
||||
<BulkSelectBar
|
||||
v-model="selectionModel"
|
||||
@@ -109,42 +96,18 @@ const handleAssignLabels = labels => {
|
||||
ghost
|
||||
slate
|
||||
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
|
||||
class="!px-1.5"
|
||||
class="!px-1"
|
||||
@click="emitClearSelection"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<div
|
||||
v-on-click-outside="closeLabelSelector"
|
||||
class="relative flex items-center"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-tags"
|
||||
:label="t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS')"
|
||||
:disabled="!selectedCount || isLoading"
|
||||
:is-loading="isLoading"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="toggleLabelSelector"
|
||||
/>
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<LabelActions
|
||||
v-if="showLabelSelector"
|
||||
class="[&>.triangle]:!hidden [&>div>button]:!hidden ltr:!right-0 rtl:!left-0 top-8 mt-0.5"
|
||||
@assign="handleAssignLabels"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
<BulkLabelActions
|
||||
type="contact"
|
||||
:is-loading="isLoading"
|
||||
:disabled="!selectedCount"
|
||||
@assign="handleAssignLabels"
|
||||
/>
|
||||
<Policy :permissions="['administrator']">
|
||||
<Button
|
||||
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
@@ -156,7 +119,7 @@ const handleAssignLabels = labels => {
|
||||
:aria-label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:disabled="!selectedCount || isLoading"
|
||||
:is-loading="isLoading"
|
||||
class="!px-1.5 [&>span:nth-child(2)]:hidden"
|
||||
class="!px-2 [&>span:nth-child(2)]:hidden"
|
||||
@click="emit('deleteSelected')"
|
||||
/>
|
||||
</Policy>
|
||||
|
||||
@@ -23,15 +23,15 @@ export default {
|
||||
<template>
|
||||
<NextButton
|
||||
v-tooltip.left="$t('CONVERSATION.SWITCH_VIEW_LAYOUT')"
|
||||
:icon="
|
||||
isOnExpandedLayout
|
||||
? 'i-lucide-arrow-left-to-line'
|
||||
: 'i-lucide-arrow-right-to-line'
|
||||
"
|
||||
icon="i-woot-expand-list"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0 md:inline-flex hidden"
|
||||
class="flex-shrink-0 md:inline-flex hidden"
|
||||
:class="{
|
||||
'ltr:!rotate-180 rtl:!rotate-0': isOnExpandedLayout,
|
||||
'ltr:!rotate-0 rtl:!rotate-180': !isOnExpandedLayout,
|
||||
}"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user