feat(v5): New expanded layout for chat list
This commit is contained in:
@@ -1,77 +1,34 @@
|
||||
<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,
|
||||
} from 'dashboard/composables/store.js';
|
||||
|
||||
// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
|
||||
// from the current one. But we should consider using tanstack virtual in the future
|
||||
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
||||
import ChatListHeader from './ChatListHeader.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 ConversationList from './ConversationList.vue';
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import IntersectionObserver from './IntersectionObserver.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.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 { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
|
||||
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 { useEventListener } from '@vueuse/core';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
|
||||
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';
|
||||
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
|
||||
const props = defineProps({
|
||||
conversationInbox: { type: [String, Number], default: 0 },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
@@ -85,34 +42,19 @@ const props = defineProps({
|
||||
const emit = defineEmits(['conversationLoad']);
|
||||
const { uiSettings } = useUISettings();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
const conversationListRef = ref(null);
|
||||
const conversationDynamicScroller = ref(null);
|
||||
|
||||
provide('contextMenuElementTarget', conversationDynamicScroller);
|
||||
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 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 chatListFiltersRef = ref(null);
|
||||
|
||||
// Provide trigger for label recalculation (parent → child pattern)
|
||||
const recalculateLabelsKey = ref(0);
|
||||
provide('recalculateLabelsKey', recalculateLabelsKey);
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const chatLists = useMapGetter('getFilteredConversations');
|
||||
@@ -123,18 +65,10 @@ 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 getConversationById = useMapGetter('getConversationById');
|
||||
const folders = useMapGetter('customViews/getConversationCustomViews');
|
||||
|
||||
useChatListKeyboardEvents(conversationListRef);
|
||||
const {
|
||||
selectedConversations,
|
||||
selectedInboxes,
|
||||
@@ -143,63 +77,28 @@ const {
|
||||
selectAllConversations,
|
||||
resetBulkActions,
|
||||
isConversationSelected,
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onRemoveLabels,
|
||||
onAssignTeamsForBulk,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
|
||||
const {
|
||||
initializeStatusAndAssigneeFilterToModal,
|
||||
initializeInboxTeamAndLabelFilterToModal,
|
||||
} = useFilter({
|
||||
filteri18nKey: 'FILTER',
|
||||
attributeModel: 'conversation_attribute',
|
||||
});
|
||||
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
// computed
|
||||
const intersectionObserverOptions = computed(() => {
|
||||
return {
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
};
|
||||
});
|
||||
|
||||
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 isFilterModalOpen = computed(() => {
|
||||
return chatListFiltersRef.value?.showAdvancedFilters ?? false;
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -243,11 +142,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
|
||||
@@ -317,7 +211,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');
|
||||
@@ -339,7 +233,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);
|
||||
@@ -350,7 +244,7 @@ const conversationList = computed(() => {
|
||||
});
|
||||
|
||||
const showEndOfListMessage = computed(() => {
|
||||
return (
|
||||
return !!(
|
||||
conversationList.value.length &&
|
||||
hasCurrentPageEndReached.value &&
|
||||
!chatListLoading.value
|
||||
@@ -384,205 +278,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);
|
||||
}
|
||||
@@ -599,7 +359,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) {
|
||||
@@ -607,17 +367,6 @@ function loadMoreConversations() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add a method to handle scroll events
|
||||
function handleScroll() {
|
||||
const scroller = conversationDynamicScroller.value;
|
||||
if (scroller && scroller.hasScrollbar) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
|
||||
if (scrollHeight - (scrollTop + clientHeight) < 100) {
|
||||
loadMoreConversations();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssigneeTab(selectedTab) {
|
||||
if (activeAssigneeTab.value !== selectedTab) {
|
||||
resetBulkActions();
|
||||
@@ -626,6 +375,8 @@ function updateAssigneeTab(selectedTab) {
|
||||
if (!currentPage.value) {
|
||||
fetchConversations();
|
||||
}
|
||||
// Trigger label position recalculation
|
||||
recalculateLabelsKey.value += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,174 +389,10 @@ 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,
|
||||
customAttributes = null
|
||||
) {
|
||||
const payload = {
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
};
|
||||
|
||||
if (customAttributes) {
|
||||
payload.customAttributes = customAttributes;
|
||||
}
|
||||
|
||||
store.dispatch('toggleStatus', payload).then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
});
|
||||
}
|
||||
|
||||
function handleResolveConversation(conversationId, status, snoozedUntil) {
|
||||
if (status !== wootConstants.STATUS_TYPE.RESOLVED) {
|
||||
toggleConversationStatus(conversationId, status, snoozedUntil);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for required attributes before resolving
|
||||
const conversation = getConversationById.value(conversationId);
|
||||
const currentCustomAttributes = conversation?.custom_attributes || {};
|
||||
const { hasMissing, missing } = checkMissingAttributes(
|
||||
currentCustomAttributes
|
||||
);
|
||||
|
||||
if (hasMissing) {
|
||||
// Pass conversation context through the modal's API
|
||||
const conversationContext = {
|
||||
id: conversationId,
|
||||
snoozedUntil,
|
||||
};
|
||||
resolveAttributesModalRef.value?.open(
|
||||
missing,
|
||||
currentCustomAttributes,
|
||||
conversationContext
|
||||
);
|
||||
} else {
|
||||
toggleConversationStatus(conversationId, status, snoozedUntil);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResolveWithAttributes({ attributes, context }) {
|
||||
if (context) {
|
||||
const existingConversation = getConversationById.value(context.id);
|
||||
const currentCustomAttributes =
|
||||
existingConversation?.custom_attributes || {};
|
||||
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
|
||||
|
||||
toggleConversationStatus(
|
||||
context.id,
|
||||
wootConstants.STATUS_TYPE.RESOLVED,
|
||||
context.snoozedUntil,
|
||||
mergedAttributes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function allSelectedConversationsStatus(status) {
|
||||
if (!selectedConversations.value.length) return false;
|
||||
return selectedConversations.value.every(item => {
|
||||
return getConversationById.value(item)?.status === status;
|
||||
return store.getters.getConversationById(item)?.status === status;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -822,8 +409,6 @@ useEmitter('fetch_conversation_stats', () => {
|
||||
store.dispatch('conversationStats/get', conversationFilters.value);
|
||||
});
|
||||
|
||||
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('setChatListFilters', conversationFilters.value);
|
||||
setFiltersFromUISettings();
|
||||
@@ -835,46 +420,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('removeLabels', onRemoveLabels);
|
||||
provide('updateConversationStatus', handleResolveConversation);
|
||||
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()
|
||||
@@ -883,6 +441,10 @@ watch(
|
||||
computed(() => props.conversationType),
|
||||
() => resetAndFetchData()
|
||||
);
|
||||
watch(
|
||||
computed(() => props.foldersId),
|
||||
() => resetAndFetchData()
|
||||
);
|
||||
|
||||
watch(activeFolder, (newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
@@ -904,7 +466,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1"
|
||||
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1 relative"
|
||||
:class="[
|
||||
{ hidden: !showConversationList },
|
||||
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
|
||||
@@ -919,32 +481,25 @@ 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"
|
||||
:is-filter-modal-open="isFilterModalOpen"
|
||||
@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
|
||||
@@ -954,114 +509,45 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
is-compact
|
||||
@chat-tab-change="updateAssigneeTab"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="!chatListLoading && !conversationList.length"
|
||||
class="flex overflow-auto justify-center items-center p-4"
|
||||
>
|
||||
{{ $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"
|
||||
:all-conversations-selected="allConversationsSelected"
|
||||
:selected-inboxes="uniqueInboxes"
|
||||
:show-open-action="allSelectedConversationsStatus('open')"
|
||||
:show-resolved-action="allSelectedConversationsStatus('resolved')"
|
||||
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
|
||||
:class="isOnExpandedLayout && 'sm:!w-[24rem] !w-full'"
|
||||
@select-all-conversations="toggleSelectAll"
|
||||
@assign-agent="onAssignAgent"
|
||||
@update-conversations="onUpdateConversations"
|
||||
@assign-labels="onAssignLabels"
|
||||
@assign-team="onAssignTeamsForBulk"
|
||||
/>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
|
||||
<p
|
||||
v-if="!chatListLoading && !conversationList.length"
|
||||
class="flex items-center justify-center p-4 overflow-auto"
|
||||
>
|
||||
<DynamicScroller
|
||||
ref="conversationDynamicScroller"
|
||||
:items="conversationList"
|
||||
:min-item-size="24"
|
||||
class="overflow-auto w-full h-full"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<!--
|
||||
If we encounter resizing issues, we can set the `watchData` prop to true
|
||||
this will deeply watch the entire object instead of just size dependencies
|
||||
But it can impact performance
|
||||
-->
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:data-index="index"
|
||||
:size-dependencies="[
|
||||
item.messages,
|
||||
item.labels,
|
||||
item.uuid,
|
||||
item.inbox_id,
|
||||
]"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
<template #after>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</div>
|
||||
<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>
|
||||
<ConversationResolveAttributesModal
|
||||
ref="resolveAttributesModalRef"
|
||||
@submit="handleResolveWithAttributes"
|
||||
{{ $t('CHAT_LIST.LIST.404') }}
|
||||
</p>
|
||||
|
||||
<ConversationList
|
||||
:items="conversationList"
|
||||
:is-loading="chatListLoading"
|
||||
:show-end-of-list-message="showEndOfListMessage"
|
||||
:is-context-menu-open="isContextMenuOpen"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:is-expanded-layout="isOnExpandedLayout"
|
||||
@load-more="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
<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,
|
||||
showAdvancedFilters,
|
||||
});
|
||||
</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>
|
||||
@@ -16,15 +16,10 @@ const props = defineProps({
|
||||
isOnExpandedLayout: { type: Boolean, required: true },
|
||||
conversationStats: { type: Object, required: true },
|
||||
isListLoading: { type: Boolean, required: true },
|
||||
isFilterModalOpen: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'addFolders',
|
||||
'deleteFolders',
|
||||
'resetFilters',
|
||||
'basicFilterChange',
|
||||
'filtersModal',
|
||||
]);
|
||||
const emit = defineEmits(['basicFilterChange', 'filtersModal']);
|
||||
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
@@ -56,61 +51,33 @@ 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"
|
||||
:title="pageTitle"
|
||||
>
|
||||
<h1 class="text-heading-2 truncate text-n-slate-12" :title="pageTitle">
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
<span
|
||||
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
|
||||
@@ -118,8 +85,8 @@ const toggleConversationLayout = () => {
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
|
||||
icon="i-lucide-pen-line"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
sm
|
||||
:variant="isFilterModalOpen ? 'faded' : 'ghost'"
|
||||
@click="emit('filtersModal')"
|
||||
/>
|
||||
<div
|
||||
@@ -128,15 +95,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
|
||||
@@ -144,8 +102,8 @@ const toggleConversationLayout = () => {
|
||||
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
||||
icon="i-lucide-list-filter"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
sm
|
||||
:variant="isFilterModalOpen ? 'faded' : 'ghost'"
|
||||
@click="emit('filtersModal')"
|
||||
/>
|
||||
<div
|
||||
@@ -154,11 +112,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,51 +1,170 @@
|
||||
<script>
|
||||
import ConversationCard from './widgets/conversation/ConversationCard.vue';
|
||||
export default {
|
||||
components: {
|
||||
ConversationCard,
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
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 { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardV5.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
source: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
inject: [
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'assignAgent',
|
||||
'assignTeam',
|
||||
'assignLabels',
|
||||
'removeLabels',
|
||||
'updateConversationStatus',
|
||||
'toggleContextMenu',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'isConversationSelected',
|
||||
'deleteConversation',
|
||||
],
|
||||
props: {
|
||||
source: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
teamId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
foldersId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
showAssignee: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
teamId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
};
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
foldersId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
showAssignee: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isExpandedLayout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'deleteConversation',
|
||||
'showResolveAttributesModal',
|
||||
'redirectToList',
|
||||
]);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectConversation = inject('selectConversation');
|
||||
const deSelectConversation = inject('deSelectConversation');
|
||||
const toggleContextMenu = inject('toggleContextMenu');
|
||||
const isConversationSelected = inject('isConversationSelected');
|
||||
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const { onAssignAgent, onAssignLabels, onRemoveLabels } = 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 toggleConversationStatus(
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
customAttributes = null
|
||||
) {
|
||||
const payload = {
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
};
|
||||
|
||||
if (customAttributes) {
|
||||
payload.customAttributes = customAttributes;
|
||||
}
|
||||
|
||||
store.dispatch('toggleStatus', payload).then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
});
|
||||
}
|
||||
|
||||
function updateConversationStatus(conversationId, status, snoozedUntil) {
|
||||
if (status !== wootConstants.STATUS_TYPE.RESOLVED) {
|
||||
toggleConversationStatus(conversationId, status, snoozedUntil);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for required attributes before resolving
|
||||
const conversation = store.getters.getConversationById(conversationId);
|
||||
const currentCustomAttributes = conversation?.custom_attributes || {};
|
||||
const { hasMissing, missing } = checkMissingAttributes(
|
||||
currentCustomAttributes
|
||||
);
|
||||
|
||||
if (hasMissing) {
|
||||
// Emit event to parent to show modal
|
||||
emit('showResolveAttributesModal', {
|
||||
conversationId,
|
||||
snoozedUntil,
|
||||
missing,
|
||||
currentCustomAttributes,
|
||||
});
|
||||
} else {
|
||||
toggleConversationStatus(conversationId, status, snoozedUntil);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
emit('redirectToList');
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(conversationId) {
|
||||
try {
|
||||
await store.dispatch('markMessagesRead', {
|
||||
id: conversationId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
emit('deleteConversation', props.source.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,18 +177,19 @@ export default {
|
||||
:conversation-type="conversationType"
|
||||
:selected="isConversationSelected(source.id)"
|
||||
:show-assignee="showAssignee"
|
||||
:is-expanded-layout="isExpandedLayout"
|
||||
enable-context-menu
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
@assign-agent="assignAgent"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-team="assignTeam"
|
||||
@assign-label="assignLabels"
|
||||
@remove-label="removeLabels"
|
||||
@assign-label="onAssignLabels"
|
||||
@remove-label="onRemoveLabels"
|
||||
@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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
<script setup>
|
||||
import { provide, useTemplateRef, shallowRef, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import { useInfiniteScroll, useBreakpoints } from '@vueuse/core';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
showEndOfListMessage: { type: Boolean, default: false },
|
||||
isContextMenuOpen: { type: Boolean, default: false },
|
||||
label: { type: String, default: '' },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
conversationType: { type: String, default: '' },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
isExpandedLayout: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['loadMore']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const parentRef = useTemplateRef('parentRef');
|
||||
const virtualizerRef = shallowRef(null);
|
||||
const deleteConversationDialogRef = ref(null);
|
||||
const conversationToDelete = ref(null);
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
|
||||
provide('contextMenuElementTarget', parentRef);
|
||||
|
||||
const breakpoints = useBreakpoints({
|
||||
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
|
||||
});
|
||||
const isLargeScreen = breakpoints.greaterOrEqual('lg');
|
||||
provide('isLargeScreen', isLargeScreen);
|
||||
|
||||
useInfiniteScroll(
|
||||
parentRef,
|
||||
() => {
|
||||
if (props.isLoading || props.showEndOfListMessage) return;
|
||||
emit('loadMore');
|
||||
},
|
||||
{
|
||||
distance: 400,
|
||||
}
|
||||
);
|
||||
|
||||
// Keyboard navigation for conversation list
|
||||
const handleConversationNavigation = direction => {
|
||||
if (!parentRef.value || props.items.length === 0) return;
|
||||
|
||||
const activeElement = parentRef.value.querySelector('.active');
|
||||
if (!activeElement) {
|
||||
// No active conversation, select first one
|
||||
const firstWrapper = parentRef.value.querySelector('[data-id]');
|
||||
firstWrapper?.firstElementChild?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find current conversation ID from active element
|
||||
const currentWrapper = activeElement.closest('[data-id]');
|
||||
const currentId = currentWrapper?.dataset.id;
|
||||
if (!currentId) return;
|
||||
|
||||
// Find index in data array
|
||||
const currentIndex = props.items.findIndex(
|
||||
item => String(item.id) === currentId
|
||||
);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
// Calculate target index
|
||||
const delta = direction === 'previous' ? -1 : 1;
|
||||
const targetIndex = currentIndex + delta;
|
||||
|
||||
// Clamp to valid range
|
||||
if (targetIndex < 0 || targetIndex >= props.items.length) return;
|
||||
|
||||
// Find and click target conversation
|
||||
const targetId = props.items[targetIndex].id;
|
||||
const targetWrapper = parentRef.value.querySelector(
|
||||
`[data-id="${targetId}"]`
|
||||
);
|
||||
targetWrapper?.firstElementChild?.click();
|
||||
};
|
||||
|
||||
useKeyboardEvents({
|
||||
'Alt+KeyJ': {
|
||||
action: () => handleConversationNavigation('previous'),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyK': {
|
||||
action: () => handleConversationNavigation('next'),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
function handleDeleteConversation(conversationId) {
|
||||
conversationToDelete.value = conversationId;
|
||||
deleteConversationDialogRef.value?.open();
|
||||
}
|
||||
|
||||
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 (props.teamId) {
|
||||
path = `/app/accounts/${accountId}/team/${props.teamId}`;
|
||||
} else if (props.label) {
|
||||
path = `/app/accounts/${accountId}/label/${props.label}`;
|
||||
} else if (inboxId) {
|
||||
path = `/app/accounts/${accountId}/inbox/${inboxId}`;
|
||||
}
|
||||
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
async function deleteConversation() {
|
||||
if (!conversationToDelete.value) return;
|
||||
|
||||
try {
|
||||
await store.dispatch('deleteConversation', conversationToDelete.value);
|
||||
redirectToConversationList();
|
||||
deleteConversationDialogRef.value?.close();
|
||||
conversationToDelete.value = null;
|
||||
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleShowResolveAttributesModal({
|
||||
conversationId,
|
||||
snoozedUntil,
|
||||
missing,
|
||||
currentCustomAttributes,
|
||||
}) {
|
||||
const conversationContext = {
|
||||
id: conversationId,
|
||||
snoozedUntil,
|
||||
};
|
||||
resolveAttributesModalRef.value?.open(
|
||||
missing,
|
||||
currentCustomAttributes,
|
||||
conversationContext
|
||||
);
|
||||
}
|
||||
|
||||
function toggleConversationStatus(
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
customAttributes = null
|
||||
) {
|
||||
const payload = {
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
};
|
||||
|
||||
if (customAttributes) {
|
||||
payload.customAttributes = customAttributes;
|
||||
}
|
||||
|
||||
store.dispatch('toggleStatus', payload).then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
});
|
||||
}
|
||||
|
||||
function handleResolveWithAttributes({ attributes, context }) {
|
||||
if (context) {
|
||||
const existingConversation = store.getters.getConversationById(context.id);
|
||||
const currentCustomAttributes =
|
||||
existingConversation?.custom_attributes || {};
|
||||
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
|
||||
|
||||
toggleConversationStatus(
|
||||
context.id,
|
||||
wootConstants.STATUS_TYPE.RESOLVED,
|
||||
context.snoozedUntil,
|
||||
mergedAttributes
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="parentRef"
|
||||
class="flex-1 w-full h-full touch-pan-y overflow-x-hidden overscroll-contain [-webkit-overflow-scrolling:touch] [contain:strict] [overflow-anchor:none] [will-change:scroll-position] px-2 pt-2"
|
||||
:class="isContextMenuOpen ? 'overflow-hidden' : 'overflow-y-auto'"
|
||||
>
|
||||
<Virtualizer
|
||||
ref="virtualizerRef"
|
||||
:data="items"
|
||||
:scroll-ref="parentRef"
|
||||
:item-size="isLargeScreen && isExpandedLayout ? 48 : 76"
|
||||
:buffer-size="100"
|
||||
item="div"
|
||||
class="[&>div]:after:content-[''] [&>div]:after:absolute [&>div]:after:bottom-0 [&>div]:after:left-0 [&>div]:after:right-0 [&>div]:after:h-px [&>div]:after:bg-n-weak [&>div]:after:pointer-events-none [&>div:has(*:hover)]:after:!bg-n-surface-1 [&>div:has(+_*:hover)]:after:!bg-n-surface-1 [&>div:has(.active)]:after:!bg-n-surface-1 [&>div:has(+_*_.active)]:after:!bg-n-surface-1 [&>div:has(.selected)]:after:!bg-n-surface-1 [&>div:has(+_*_.selected)]:after:!bg-n-surface-1"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div :key="item.id" :data-id="item.id">
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssignee"
|
||||
:is-expanded-layout="isExpandedLayout"
|
||||
@delete-conversation="handleDeleteConversation"
|
||||
@show-resolve-attributes-modal="handleShowResolveAttributesModal"
|
||||
@redirect-to-list="redirectToConversationList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Virtualizer>
|
||||
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
type="alert"
|
||||
:title="
|
||||
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
|
||||
conversationId: conversationToDelete,
|
||||
})
|
||||
"
|
||||
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
|
||||
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
|
||||
@confirm="deleteConversation"
|
||||
/>
|
||||
|
||||
<ConversationResolveAttributesModal
|
||||
ref="resolveAttributesModalRef"
|
||||
@submit="handleResolveWithAttributes"
|
||||
/>
|
||||
|
||||
<div class="py-2">
|
||||
<div v-if="isLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-sm text-n-slate-10 whitespace-nowrap"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
|
||||
|
||||
const props = defineProps({
|
||||
index: {
|
||||
@@ -28,11 +29,14 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const { formatCompactWithDecimal } = useNumberFormatter();
|
||||
|
||||
const activeIndex = inject('activeIndex');
|
||||
const updateActiveIndex = inject('updateActiveIndex');
|
||||
|
||||
const active = computed(() => props.index === activeIndex.value);
|
||||
const getItemCount = computed(() => props.count);
|
||||
const count = computed(() => props.count);
|
||||
const getItemCount = computed(() => formatCompactWithDecimal(count.value));
|
||||
|
||||
const onTabClick = event => {
|
||||
event.preventDefault();
|
||||
@@ -59,7 +63,11 @@ const onTabClick = event => {
|
||||
{{ name }}
|
||||
<div
|
||||
v-if="showBadge"
|
||||
class="rounded-full h-5 flex items-center justify-center text-xs font-medium my-0 ltr:ml-1 rtl:mr-1 px-1.5 py-0 min-w-[20px]"
|
||||
v-tooltip.top="{
|
||||
content: count,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="rounded-full h-5 flex items-center justify-center font-interDisplay text-xs font-medium 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 h-10"
|
||||
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:left-0 rtl:right-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ActiveFilterPreview>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
+9
-5
@@ -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,18 +132,21 @@ 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"
|
||||
slate
|
||||
faded
|
||||
xs
|
||||
sm
|
||||
:variant="showActionsDropdown ? 'faded' : 'ghost'"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<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,
|
||||
|
||||
@@ -243,7 +243,7 @@ const deleteConversation = () => {
|
||||
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
|
||||
'bg-n-slate-2': selected,
|
||||
'px-0': compact,
|
||||
'px-3': !compact,
|
||||
'px-2 rounded-lg': !compact,
|
||||
}"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
|
||||
-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-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
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 bottom-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 md:[&>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-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
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="bottom-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 mb-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 [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
|
||||
: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/get');
|
||||
});
|
||||
</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-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
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 bottom-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-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
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 bottom-8 w-36"
|
||||
@action="handleUpdate"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+160
-299
@@ -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,313 +12,172 @@ 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">
|
||||
<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"
|
||||
/>
|
||||
</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"
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
:show-snooze="!showSnoozedAction"
|
||||
@update="updateConversations"
|
||||
@close="showUpdateActions = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<AgentSelector
|
||||
v-if="showAgentsList"
|
||||
class="agent-actions-box"
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
@select="submit"
|
||||
@close="showAgentsList = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<TeamActions
|
||||
v-if="showTeamsList"
|
||||
class="team-actions-box"
|
||||
@assign-team="assignTeam"
|
||||
@close="showTeamsList = false"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
<div v-if="allConversationsSelected" class="bulk-action__alert">
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
<woot-modal
|
||||
v-model:show="showCustomTimeSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-2"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-show="conversations.length > 0"
|
||||
class="px-2 absolute bottom-4 left-1/2 -translate-x-1/2 z-30 w-full origin-bottom"
|
||||
>
|
||||
<CustomSnoozeModal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="customSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
<div
|
||||
v-if="allConversationsSelected"
|
||||
class="bg-n-amber-2 outline -outline-offset-1 outline-1 outline-n-amber-5 rounded-lg text-sm mb-2 py-1.5 px-2 text-n-amber-text"
|
||||
>
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between p-2 bg-n-button-color outline outline-1 -outline-offset-1 rounded-[10px] outline-n-weak shadow-[0_0_12px_0_rgba(27,40,59,0.08)]"
|
||||
>
|
||||
<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
|
||||
:label="$t('BULK_ACTION.CLEAR_SELECTION')"
|
||||
ghost
|
||||
class="!text-n-blue-11 !px-1 !h-6"
|
||||
sm
|
||||
@click="allSelected = false"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<BulkLabelActions @assign="onAssignLabels" />
|
||||
<BulkUpdateActions
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
:show-snooze="!showSnoozedAction"
|
||||
@update="onUpdateConversations"
|
||||
/>
|
||||
<BulkAgentActions
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
@select="onAssignAgent"
|
||||
/>
|
||||
<BulkTeamActions
|
||||
:conversation-count="conversations.length"
|
||||
@select="onAssignTeam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<woot-modal
|
||||
v-model:show="showCustomTimeSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<CustomSnoozeModal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="customSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</Transition>
|
||||
</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>
|
||||
Reference in New Issue
Block a user