Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
visibleContactIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedContactIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'clearSelection',
|
||||
'assignLabels',
|
||||
'toggleAll',
|
||||
'deleteSelected',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedCount = computed(() => props.selectedContactIds.length);
|
||||
const totalVisibleContacts = computed(() => props.visibleContactIds.length);
|
||||
const showLabelSelector = ref(false);
|
||||
|
||||
const selectAllLabel = computed(() => {
|
||||
if (!totalVisibleContacts.value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return t('CONTACTS_BULK_ACTIONS.SELECT_ALL', {
|
||||
count: totalVisibleContacts.value,
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() =>
|
||||
t('CONTACTS_BULK_ACTIONS.SELECTED_COUNT', {
|
||||
count: selectedCount.value,
|
||||
})
|
||||
);
|
||||
|
||||
const allItems = computed(() =>
|
||||
props.visibleContactIds.map(id => ({
|
||||
id,
|
||||
}))
|
||||
);
|
||||
|
||||
const selectionModel = computed({
|
||||
get: () => new Set(props.selectedContactIds),
|
||||
set: newSet => {
|
||||
if (!props.visibleContactIds.length) {
|
||||
emit('toggleAll', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSelectAll =
|
||||
newSet.size === props.visibleContactIds.length && newSet.size > 0;
|
||||
emit('toggleAll', shouldSelectAll);
|
||||
},
|
||||
});
|
||||
|
||||
const emitClearSelection = () => {
|
||||
showLabelSelector.value = false;
|
||||
emit('clearSelection');
|
||||
};
|
||||
|
||||
const toggleLabelSelector = () => {
|
||||
if (!selectedCount.value || props.isLoading) return;
|
||||
showLabelSelector.value = !showLabelSelector.value;
|
||||
};
|
||||
|
||||
const closeLabelSelector = () => {
|
||||
showLabelSelector.value = false;
|
||||
};
|
||||
|
||||
const handleAssignLabels = labels => {
|
||||
emit('assignLabels', labels);
|
||||
closeLabelSelector();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 z-10 bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
|
||||
>
|
||||
<BulkSelectBar
|
||||
v-model="selectionModel"
|
||||
:all-items="allItems"
|
||||
:select-all-label="selectAllLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
|
||||
>
|
||||
<template #secondary-actions>
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
|
||||
class="!px-1.5"
|
||||
@click="emitClearSelection"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<div
|
||||
v-on-click-outside="closeLabelSelector"
|
||||
class="relative flex items-center"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-tags"
|
||||
:label="t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS')"
|
||||
:disabled="!selectedCount || isLoading"
|
||||
:is-loading="isLoading"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="toggleLabelSelector"
|
||||
/>
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<LabelActions
|
||||
v-if="showLabelSelector"
|
||||
class="[&>.triangle]:!hidden [&>div>button]:!hidden ltr:!right-0 rtl:!left-0 top-8 mt-0.5"
|
||||
@assign="handleAssignLabels"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
<Policy :permissions="['administrator']">
|
||||
<Button
|
||||
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
sm
|
||||
faded
|
||||
ruby
|
||||
icon="i-lucide-trash"
|
||||
:label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:aria-label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:disabled="!selectedCount || isLoading"
|
||||
:is-loading="isLoading"
|
||||
class="!px-1.5 [&>span:nth-child(2)]:hidden"
|
||||
@click="emit('deleteSelected')"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
OPERATOR_TYPES_1,
|
||||
OPERATOR_TYPES_2,
|
||||
OPERATOR_TYPES_3,
|
||||
OPERATOR_TYPES_5,
|
||||
} from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
|
||||
const filterTypes = [
|
||||
{
|
||||
attributeKey: 'name',
|
||||
attributeI18nKey: 'NAME',
|
||||
inputType: 'plain_text',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attribute_type: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'email',
|
||||
attributeI18nKey: 'EMAIL',
|
||||
inputType: 'plain_text',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_3,
|
||||
attribute_type: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'phone_number',
|
||||
attributeI18nKey: 'PHONE_NUMBER',
|
||||
inputType: 'plain_text',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_3,
|
||||
attribute_type: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'identifier',
|
||||
attributeI18nKey: 'IDENTIFIER',
|
||||
inputType: 'plain_text',
|
||||
dataType: 'number',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attribute_type: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'country_code',
|
||||
attributeI18nKey: 'COUNTRY',
|
||||
inputType: 'search_select',
|
||||
dataType: 'number',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attribute_type: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'city',
|
||||
attributeI18nKey: 'CITY',
|
||||
inputType: 'plain_text',
|
||||
dataType: 'Number',
|
||||
filterOperators: OPERATOR_TYPES_3,
|
||||
attribute_type: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
attributeI18nKey: 'CREATED_AT',
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'last_activity_at',
|
||||
attributeI18nKey: 'LAST_ACTIVITY',
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'referer',
|
||||
attributeI18nKey: 'REFERER_LINK',
|
||||
inputType: 'plain_text',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'blocked',
|
||||
attributeI18nKey: 'BLOCKED',
|
||||
inputType: 'search_select',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'labels',
|
||||
attributeI18nKey: 'LABELS',
|
||||
inputType: 'multi_select',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
];
|
||||
|
||||
export const filterAttributeGroups = [
|
||||
{
|
||||
name: 'Standard Filters',
|
||||
i18nGroup: 'STANDARD_FILTERS',
|
||||
attributes: [
|
||||
{
|
||||
key: 'name',
|
||||
i18nKey: 'NAME',
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
i18nKey: 'EMAIL',
|
||||
},
|
||||
{
|
||||
key: 'phone_number',
|
||||
i18nKey: 'PHONE_NUMBER',
|
||||
},
|
||||
{
|
||||
key: 'identifier',
|
||||
i18nKey: 'IDENTIFIER',
|
||||
},
|
||||
{
|
||||
key: 'country_code',
|
||||
i18nKey: 'COUNTRY',
|
||||
},
|
||||
{
|
||||
key: 'city',
|
||||
i18nKey: 'CITY',
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
i18nKey: 'CREATED_AT',
|
||||
},
|
||||
{
|
||||
key: 'last_activity_at',
|
||||
i18nKey: 'LAST_ACTIVITY',
|
||||
},
|
||||
{
|
||||
key: 'blocked',
|
||||
i18nKey: 'BLOCKED',
|
||||
},
|
||||
{
|
||||
key: 'labels',
|
||||
i18nKey: 'LABELS',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default filterTypes;
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup>
|
||||
import { onMounted, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import ContactsDetailsLayout from 'dashboard/components-next/Contacts/ContactsDetailsLayout.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDetails.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
|
||||
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
|
||||
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
|
||||
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const contact = useMapGetter('contacts/getContactById');
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const activeTab = ref('attributes');
|
||||
const contactMergeRef = ref(null);
|
||||
|
||||
const isFetchingItem = computed(() => uiFlags.value.isFetchingItem);
|
||||
const isMergingContact = computed(() => uiFlags.value.isMerging);
|
||||
const isUpdatingContact = computed(() => uiFlags.value.isUpdating);
|
||||
|
||||
const selectedContact = computed(() => contact.value(route.params.contactId));
|
||||
|
||||
const showSpinner = computed(
|
||||
() => isFetchingItem.value || isMergingContact.value
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const CONTACT_TABS_OPTIONS = [
|
||||
{ key: 'ATTRIBUTES', value: 'attributes' },
|
||||
{ key: 'HISTORY', value: 'history' },
|
||||
{ key: 'NOTES', value: 'notes' },
|
||||
{ key: 'MERGE', value: 'merge' },
|
||||
];
|
||||
|
||||
const tabs = computed(() => {
|
||||
return CONTACT_TABS_OPTIONS.map(tab => ({
|
||||
label: t(`CONTACTS_LAYOUT.SIDEBAR.TABS.${tab.key}`),
|
||||
value: tab.value,
|
||||
}));
|
||||
});
|
||||
|
||||
const activeTabIndex = computed(() => {
|
||||
return CONTACT_TABS_OPTIONS.findIndex(v => v.value === activeTab.value);
|
||||
});
|
||||
|
||||
const goToContactsList = () => {
|
||||
if (window.history.state?.back || window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push(`/app/accounts/${route.params.accountId}/contacts?page=1`);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchActiveContact = async () => {
|
||||
if (route.params.contactId) {
|
||||
await store.dispatch('contacts/show', { id: route.params.contactId });
|
||||
await store.dispatch(
|
||||
'contacts/fetchContactableInbox',
|
||||
route.params.contactId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = tab => {
|
||||
activeTab.value = tab.value;
|
||||
};
|
||||
|
||||
const fetchContactNotes = () => {
|
||||
const { contactId } = route.params;
|
||||
if (contactId) store.dispatch('contactNotes/get', { contactId });
|
||||
};
|
||||
|
||||
const fetchContactConversations = () => {
|
||||
const { contactId } = route.params;
|
||||
if (contactId) store.dispatch('contactConversations/get', contactId);
|
||||
};
|
||||
|
||||
const fetchAttributes = () => {
|
||||
store.dispatch('attributes/get');
|
||||
};
|
||||
|
||||
const toggleContactBlock = async isBlocked => {
|
||||
const ALERT_MESSAGES = {
|
||||
success: {
|
||||
block: t('CONTACTS_LAYOUT.HEADER.ACTIONS.BLOCK_SUCCESS_MESSAGE'),
|
||||
unblock: t('CONTACTS_LAYOUT.HEADER.ACTIONS.UNBLOCK_SUCCESS_MESSAGE'),
|
||||
},
|
||||
error: {
|
||||
block: t('CONTACTS_LAYOUT.HEADER.ACTIONS.BLOCK_ERROR_MESSAGE'),
|
||||
unblock: t('CONTACTS_LAYOUT.HEADER.ACTIONS.UNBLOCK_ERROR_MESSAGE'),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await store.dispatch(`contacts/update`, {
|
||||
...selectedContact.value,
|
||||
blocked: !isBlocked,
|
||||
});
|
||||
useAlert(
|
||||
isBlocked ? ALERT_MESSAGES.success.unblock : ALERT_MESSAGES.success.block
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
isBlocked ? ALERT_MESSAGES.error.unblock : ALERT_MESSAGES.error.block
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchActiveContact();
|
||||
fetchContactNotes();
|
||||
fetchContactConversations();
|
||||
fetchAttributes();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1"
|
||||
>
|
||||
<ContactsDetailsLayout
|
||||
:button-label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
|
||||
:selected-contact="selectedContact"
|
||||
is-detail-view
|
||||
:show-pagination-footer="false"
|
||||
:is-updating="isUpdatingContact"
|
||||
@go-to-contacts-list="goToContactsList"
|
||||
@toggle-block="toggleContactBlock"
|
||||
>
|
||||
<div
|
||||
v-if="showSpinner"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<ContactDetails
|
||||
v-else-if="selectedContact"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="goToContactsList"
|
||||
/>
|
||||
<template #sidebar>
|
||||
<div class="px-6">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeTabIndex"
|
||||
class="w-full [&>button]:w-full bg-n-alpha-black2"
|
||||
@tab-changed="handleTabChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isFetchingItem"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<template v-else>
|
||||
<ContactCustomAttributes
|
||||
v-if="activeTab === 'attributes'"
|
||||
:selected-contact="selectedContact"
|
||||
/>
|
||||
<ContactNotes v-if="activeTab === 'notes'" />
|
||||
<ContactHistory v-if="activeTab === 'history'" />
|
||||
<ContactMerge
|
||||
v-if="activeTab === 'merge'"
|
||||
ref="contactMergeRef"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="goToContactsList"
|
||||
@reset-tab="handleTabChange(CONTACT_TABS_OPTIONS[0])"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</ContactsDetailsLayout>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,516 @@
|
||||
<script setup>
|
||||
import { onMounted, computed, ref, reactive, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
||||
|
||||
import ContactsListLayout from 'dashboard/components-next/Contacts/ContactsListLayout.vue';
|
||||
import ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/ContactEmptyState.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
|
||||
import ContactsBulkActionBar from '../components/ContactsBulkActionBar.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import BulkActionsAPI from 'dashboard/api/bulkActions';
|
||||
|
||||
const DEFAULT_SORT_FIELD = 'last_activity_at';
|
||||
const DEBOUNCE_DELAY = 300;
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { updateUISettings, uiSettings } = useUISettings();
|
||||
|
||||
const contacts = useMapGetter('contacts/getContactsList');
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const customViewsUiFlags = useMapGetter('customViews/getUIFlags');
|
||||
const segments = useMapGetter('customViews/getContactCustomViews');
|
||||
const appliedFilters = useMapGetter('contacts/getAppliedContactFilters');
|
||||
const meta = useMapGetter('contacts/getMeta');
|
||||
|
||||
const searchQuery = computed(() => route.query?.search);
|
||||
const searchValue = ref(searchQuery.value || '');
|
||||
const pageNumber = computed(() => Number(route.query?.page) || 1);
|
||||
// For infinite scroll in search, track page internally
|
||||
const searchPageNumber = ref(1);
|
||||
const isLoadingMore = ref(false);
|
||||
|
||||
const parseSortSettings = (sortString = '') => {
|
||||
const hasDescending = sortString.startsWith('-');
|
||||
const sortField = hasDescending ? sortString.slice(1) : sortString;
|
||||
return {
|
||||
sort: sortField || DEFAULT_SORT_FIELD,
|
||||
order: hasDescending ? '-' : '',
|
||||
};
|
||||
};
|
||||
|
||||
const { contacts_sort_by: contactSortBy = '' } = uiSettings.value ?? {};
|
||||
const { sort: initialSort, order: initialOrder } =
|
||||
parseSortSettings(contactSortBy);
|
||||
|
||||
const sortState = reactive({
|
||||
activeSort: initialSort,
|
||||
activeOrdering: initialOrder,
|
||||
});
|
||||
|
||||
const activeLabel = computed(() => route.params.label);
|
||||
const activeSegmentId = computed(() => route.params.segmentId);
|
||||
const isFetchingList = computed(
|
||||
() => uiFlags.value.isFetching || customViewsUiFlags.value.isFetching
|
||||
);
|
||||
const currentPage = computed(() => Number(meta.value?.currentPage));
|
||||
const totalItems = computed(() => meta.value?.count);
|
||||
const hasMore = computed(() => meta.value?.hasMore ?? false);
|
||||
const isSearchView = computed(() => !!searchQuery.value);
|
||||
|
||||
const selectedContactIds = ref([]);
|
||||
const isBulkActionLoading = ref(false);
|
||||
const bulkDeleteDialogRef = ref(null);
|
||||
const selectedCount = computed(() => selectedContactIds.value.length);
|
||||
const bulkDeleteDialogTitle = computed(() =>
|
||||
selectedCount.value > 1
|
||||
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.TITLE')
|
||||
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_TITLE')
|
||||
);
|
||||
const bulkDeleteDialogDescription = computed(() =>
|
||||
selectedCount.value > 1
|
||||
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.DESCRIPTION', {
|
||||
count: selectedCount.value,
|
||||
})
|
||||
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_DESCRIPTION')
|
||||
);
|
||||
const bulkDeleteDialogConfirmLabel = computed(() =>
|
||||
selectedCount.value > 1
|
||||
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_MULTIPLE')
|
||||
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_SINGLE')
|
||||
);
|
||||
const hasSelection = computed(() => selectedCount.value > 0);
|
||||
const activeSegment = computed(() => {
|
||||
if (!activeSegmentId.value) return undefined;
|
||||
return segments.value.find(view => view.id === Number(activeSegmentId.value));
|
||||
});
|
||||
|
||||
const hasContacts = computed(() => contacts.value.length > 0);
|
||||
const isContactIndexView = computed(
|
||||
() => route.name === 'contacts_dashboard_index' && pageNumber.value === 1
|
||||
);
|
||||
const isActiveView = computed(() => route.name === 'contacts_dashboard_active');
|
||||
const hasAppliedFilters = computed(() => {
|
||||
return appliedFilters.value.length > 0;
|
||||
});
|
||||
|
||||
const showEmptyStateLayout = computed(() => {
|
||||
return (
|
||||
!searchQuery.value &&
|
||||
!hasContacts.value &&
|
||||
isContactIndexView.value &&
|
||||
!hasAppliedFilters.value
|
||||
);
|
||||
});
|
||||
const showEmptyText = computed(() => {
|
||||
return (
|
||||
(searchQuery.value ||
|
||||
hasAppliedFilters.value ||
|
||||
!isContactIndexView.value) &&
|
||||
!hasContacts.value
|
||||
);
|
||||
});
|
||||
|
||||
const headerTitle = computed(() => {
|
||||
if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE');
|
||||
if (isActiveView.value) return t('CONTACTS_LAYOUT.HEADER.ACTIVE_TITLE');
|
||||
if (activeSegmentId.value) return activeSegment.value?.name;
|
||||
if (activeLabel.value) return `#${activeLabel.value}`;
|
||||
return t('CONTACTS_LAYOUT.HEADER.TITLE');
|
||||
});
|
||||
|
||||
const emptyStateMessage = computed(() => {
|
||||
if (isActiveView.value)
|
||||
return t('CONTACTS_LAYOUT.EMPTY_STATE.ACTIVE_EMPTY_STATE_TITLE');
|
||||
if (!searchQuery.value || hasAppliedFilters.value)
|
||||
return t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE');
|
||||
return t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE');
|
||||
});
|
||||
|
||||
const visibleContactIds = computed(() =>
|
||||
contacts.value.map(contact => contact.id)
|
||||
);
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedContactIds.value = [];
|
||||
};
|
||||
|
||||
const openBulkDeleteDialog = () => {
|
||||
if (!selectedContactIds.value.length || isBulkActionLoading.value) return;
|
||||
bulkDeleteDialogRef.value?.open?.();
|
||||
};
|
||||
|
||||
const toggleSelectAll = shouldSelect => {
|
||||
selectedContactIds.value = shouldSelect ? [...visibleContactIds.value] : [];
|
||||
};
|
||||
|
||||
const toggleContactSelection = ({ id, value }) => {
|
||||
const isAlreadySelected = selectedContactIds.value.includes(id);
|
||||
const shouldSelect = value ?? !isAlreadySelected;
|
||||
|
||||
if (shouldSelect && !isAlreadySelected) {
|
||||
selectedContactIds.value = [...selectedContactIds.value, id];
|
||||
} else if (!shouldSelect && isAlreadySelected) {
|
||||
selectedContactIds.value = selectedContactIds.value.filter(
|
||||
contactId => contactId !== id
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePageParam = (page, search = '') => {
|
||||
const query = {
|
||||
...route.query,
|
||||
page: page.toString(),
|
||||
...(search ? { search } : {}),
|
||||
};
|
||||
|
||||
if (!search) {
|
||||
delete query.search;
|
||||
}
|
||||
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const buildSortAttr = () =>
|
||||
`${sortState.activeOrdering}${sortState.activeSort}`;
|
||||
|
||||
const getCommonFetchParams = (page = 1) => ({
|
||||
page,
|
||||
sortAttr: buildSortAttr(),
|
||||
label: activeLabel.value,
|
||||
});
|
||||
|
||||
const fetchContacts = async (page = 1) => {
|
||||
clearSelection();
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
await store.dispatch('contacts/get', getCommonFetchParams(page));
|
||||
updatePageParam(page);
|
||||
};
|
||||
|
||||
const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
|
||||
if (!activeSegmentId.value && !hasAppliedFilters.value) return;
|
||||
clearSelection();
|
||||
await store.dispatch('contacts/filter', {
|
||||
...getCommonFetchParams(page),
|
||||
queryPayload: payload,
|
||||
});
|
||||
updatePageParam(page);
|
||||
};
|
||||
|
||||
const fetchActiveContacts = async (page = 1) => {
|
||||
clearSelection();
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
await store.dispatch('contacts/active', {
|
||||
page,
|
||||
sortAttr: buildSortAttr(),
|
||||
});
|
||||
updatePageParam(page);
|
||||
};
|
||||
|
||||
const searchContacts = debounce(async (value, page = 1, append = false) => {
|
||||
if (!append) {
|
||||
clearSelection();
|
||||
searchPageNumber.value = 1;
|
||||
}
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
searchValue.value = value;
|
||||
|
||||
if (!value) {
|
||||
updatePageParam(page);
|
||||
await fetchContacts(page);
|
||||
return;
|
||||
}
|
||||
|
||||
updatePageParam(page, value);
|
||||
await store.dispatch('contacts/search', {
|
||||
...getCommonFetchParams(page),
|
||||
search: encodeURIComponent(value),
|
||||
append,
|
||||
});
|
||||
searchPageNumber.value = page;
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
const loadMoreSearchResults = async () => {
|
||||
if (!hasMore.value || isLoadingMore.value) return;
|
||||
|
||||
isLoadingMore.value = true;
|
||||
const nextPage = searchPageNumber.value + 1;
|
||||
|
||||
await store.dispatch('contacts/search', {
|
||||
...getCommonFetchParams(nextPage),
|
||||
search: encodeURIComponent(searchValue.value),
|
||||
append: true,
|
||||
});
|
||||
|
||||
searchPageNumber.value = nextPage;
|
||||
isLoadingMore.value = false;
|
||||
};
|
||||
|
||||
const fetchContactsBasedOnContext = async page => {
|
||||
clearSelection();
|
||||
updatePageParam(page, searchValue.value);
|
||||
if (isFetchingList.value) return;
|
||||
if (searchQuery.value) {
|
||||
await searchContacts(searchQuery.value, page);
|
||||
return;
|
||||
}
|
||||
// Reset the search value when we change the view
|
||||
searchValue.value = '';
|
||||
// If we're on the active route, fetch active contacts
|
||||
if (isActiveView.value) {
|
||||
await fetchActiveContacts(page);
|
||||
return;
|
||||
}
|
||||
// If there are applied filters or active segment with query
|
||||
if (
|
||||
(hasAppliedFilters.value || activeSegment.value?.query) &&
|
||||
!activeLabel.value
|
||||
) {
|
||||
const queryPayload =
|
||||
activeSegment.value?.query || filterQueryGenerator(appliedFilters.value);
|
||||
await fetchSavedOrAppliedFilteredContact(queryPayload, page);
|
||||
return;
|
||||
}
|
||||
// Default case: fetch regular contacts + label
|
||||
await fetchContacts(page);
|
||||
};
|
||||
|
||||
const assignLabels = async labels => {
|
||||
if (!labels.length || !selectedContactIds.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
isBulkActionLoading.value = true;
|
||||
try {
|
||||
await BulkActionsAPI.create({
|
||||
type: 'Contact',
|
||||
ids: selectedContactIds.value,
|
||||
labels: { add: labels },
|
||||
});
|
||||
useAlert(t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS_SUCCESS'));
|
||||
clearSelection();
|
||||
await fetchContactsBasedOnContext(pageNumber.value);
|
||||
} catch (error) {
|
||||
useAlert(t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS_FAILED'));
|
||||
} finally {
|
||||
isBulkActionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteContacts = async () => {
|
||||
if (!selectedContactIds.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
isBulkActionLoading.value = true;
|
||||
try {
|
||||
await BulkActionsAPI.create({
|
||||
type: 'Contact',
|
||||
ids: selectedContactIds.value,
|
||||
action_name: 'delete',
|
||||
});
|
||||
useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_SUCCESS'));
|
||||
clearSelection();
|
||||
await fetchContactsBasedOnContext(pageNumber.value);
|
||||
bulkDeleteDialogRef.value?.close?.();
|
||||
} catch (error) {
|
||||
useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_FAILED'));
|
||||
} finally {
|
||||
isBulkActionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = async ({ sort, order }) => {
|
||||
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
|
||||
|
||||
await updateUISettings({
|
||||
contacts_sort_by: buildSortAttr(),
|
||||
});
|
||||
|
||||
if (searchQuery.value) {
|
||||
await searchContacts(searchValue.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveView.value) {
|
||||
await fetchActiveContacts();
|
||||
return;
|
||||
}
|
||||
|
||||
await (activeSegmentId.value || hasAppliedFilters.value
|
||||
? fetchSavedOrAppliedFilteredContact(
|
||||
activeSegmentId.value
|
||||
? activeSegment.value?.query
|
||||
: filterQueryGenerator(appliedFilters.value)
|
||||
)
|
||||
: fetchContacts());
|
||||
};
|
||||
|
||||
const createContact = async contact => {
|
||||
await store.dispatch('contacts/create', contact);
|
||||
};
|
||||
|
||||
watch(
|
||||
contacts,
|
||||
newContacts => {
|
||||
const idsOnPage = newContacts.map(contact => contact.id);
|
||||
selectedContactIds.value = selectedContactIds.value.filter(id =>
|
||||
idsOnPage.includes(id)
|
||||
);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(hasSelection, value => {
|
||||
if (!value) {
|
||||
bulkDeleteDialogRef.value?.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => uiSettings.value?.contacts_sort_by,
|
||||
newSortBy => {
|
||||
if (newSortBy) {
|
||||
const { sort, order } = parseSortSettings(newSortBy);
|
||||
sortState.activeSort = sort;
|
||||
sortState.activeOrdering = order;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
[activeLabel, activeSegment, isActiveView],
|
||||
() => {
|
||||
fetchContactsBasedOnContext(pageNumber.value);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(searchQuery, value => {
|
||||
if (isFetchingList.value) return;
|
||||
searchValue.value = value || '';
|
||||
// Reset the view if there is search query when we click on the sidebar group
|
||||
if (value === undefined) {
|
||||
if (
|
||||
isActiveView.value ||
|
||||
activeLabel.value ||
|
||||
activeSegment.value ||
|
||||
hasAppliedFilters.value
|
||||
)
|
||||
return;
|
||||
fetchContacts();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (!activeSegmentId.value) {
|
||||
if (searchQuery.value) {
|
||||
await searchContacts(searchQuery.value, pageNumber.value);
|
||||
return;
|
||||
}
|
||||
if (isActiveView.value) {
|
||||
await fetchActiveContacts(pageNumber.value);
|
||||
return;
|
||||
}
|
||||
await fetchContacts(pageNumber.value);
|
||||
} else if (activeSegment.value && activeSegmentId.value) {
|
||||
await fetchSavedOrAppliedFilteredContact(
|
||||
activeSegment.value.query,
|
||||
pageNumber.value
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1"
|
||||
>
|
||||
<ContactsListLayout
|
||||
:search-value="searchValue"
|
||||
:header-title="headerTitle"
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
:show-pagination-footer="!isFetchingList && hasContacts && !isSearchView"
|
||||
:active-sort="sortState.activeSort"
|
||||
:active-ordering="sortState.activeOrdering"
|
||||
:active-segment="activeSegment"
|
||||
:segments-id="activeSegmentId"
|
||||
:is-fetching-list="isFetchingList"
|
||||
:has-applied-filters="hasAppliedFilters"
|
||||
:use-infinite-scroll="isSearchView"
|
||||
:has-more="hasMore"
|
||||
:is-loading-more="isLoadingMore"
|
||||
@update:current-page="fetchContactsBasedOnContext"
|
||||
@search="searchContacts"
|
||||
@update:sort="handleSort"
|
||||
@apply-filter="fetchSavedOrAppliedFilteredContact"
|
||||
@clear-filters="fetchContacts"
|
||||
@load-more="loadMoreSearchResults"
|
||||
>
|
||||
<div
|
||||
v-if="isFetchingList && !(isSearchView && hasContacts)"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<ContactsBulkActionBar
|
||||
v-if="hasSelection"
|
||||
:visible-contact-ids="visibleContactIds"
|
||||
:selected-contact-ids="selectedContactIds"
|
||||
:is-loading="isBulkActionLoading"
|
||||
@toggle-all="toggleSelectAll"
|
||||
@clear-selection="clearSelection"
|
||||
@assign-labels="assignLabels"
|
||||
@delete-selected="openBulkDeleteDialog"
|
||||
/>
|
||||
<ContactEmptyState
|
||||
v-if="showEmptyStateLayout"
|
||||
class="pt-14"
|
||||
:title="t('CONTACTS_LAYOUT.EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('CONTACTS_LAYOUT.EMPTY_STATE.SUBTITLE')"
|
||||
:button-label="t('CONTACTS_LAYOUT.EMPTY_STATE.BUTTON_LABEL')"
|
||||
@create="createContact"
|
||||
/>
|
||||
<div
|
||||
v-else-if="showEmptyText"
|
||||
class="flex items-center justify-center py-10"
|
||||
>
|
||||
<span class="text-base text-n-slate-11">
|
||||
{{ emptyStateMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4 pt-4 pb-6">
|
||||
<ContactsList
|
||||
:contacts="contacts"
|
||||
:selected-contact-ids="selectedContactIds"
|
||||
@toggle-contact="toggleContactSelection"
|
||||
/>
|
||||
<Dialog
|
||||
v-if="selectedCount"
|
||||
ref="bulkDeleteDialogRef"
|
||||
type="alert"
|
||||
:title="bulkDeleteDialogTitle"
|
||||
:description="bulkDeleteDialogDescription"
|
||||
:confirm-button-label="bulkDeleteDialogConfirmLabel"
|
||||
:is-loading="isBulkActionLoading"
|
||||
@confirm="deleteContacts"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ContactsListLayout>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import ContactsIndex from './pages/ContactsIndex.vue';
|
||||
import ContactManageView from './pages/ContactManageView.vue';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
|
||||
const commonMeta = {
|
||||
featureFlag: FEATURE_FLAGS.CRM,
|
||||
permissions: ['administrator', 'agent', 'contact_manage'],
|
||||
};
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/contacts'),
|
||||
component: ContactsIndex,
|
||||
meta: commonMeta,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'contacts_dashboard_index',
|
||||
component: ContactsIndex,
|
||||
meta: commonMeta,
|
||||
},
|
||||
{
|
||||
path: 'segments/:segmentId',
|
||||
name: 'contacts_dashboard_segments_index',
|
||||
component: ContactsIndex,
|
||||
meta: commonMeta,
|
||||
},
|
||||
{
|
||||
path: 'labels/:label',
|
||||
name: 'contacts_dashboard_labels_index',
|
||||
component: ContactsIndex,
|
||||
meta: commonMeta,
|
||||
},
|
||||
{
|
||||
path: 'active',
|
||||
name: 'contacts_dashboard_active',
|
||||
component: ContactsIndex,
|
||||
meta: commonMeta,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/contacts/:contactId'),
|
||||
component: ContactManageView,
|
||||
meta: commonMeta,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'contacts_edit',
|
||||
component: ContactManageView,
|
||||
meta: commonMeta,
|
||||
},
|
||||
{
|
||||
path: 'segments/:segmentId',
|
||||
name: 'contacts_edit_segment',
|
||||
component: ContactManageView,
|
||||
meta: commonMeta,
|
||||
},
|
||||
{
|
||||
path: 'labels/:label',
|
||||
name: 'contacts_edit_label',
|
||||
component: ContactManageView,
|
||||
meta: commonMeta,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user