chore: clean up workspace and fix backend prisma build

This commit is contained in:
Ruslan Bakiev
2026-03-08 20:31:32 +07:00
parent f1cf90adc7
commit 22e04e0a34
86 changed files with 3 additions and 9333 deletions

View File

@@ -1,28 +0,0 @@
<script setup>
import { onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
defineProps({
keepAlive: { type: Boolean, default: true },
});
const store = useStore();
onMounted(() => {
store.dispatch('campaigns/get');
store.dispatch('labels/get');
});
</script>
<template>
<div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1"
>
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
<component :is="Component" />
</keep-alive>
<component :is="Component" v-else />
</router-view>
</div>
</template>

View File

@@ -1,87 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
import LiveChatCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue';
import EditLiveChatCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/EditLiveChatCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import LiveChatCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/LiveChatCampaignEmptyState.vue';
const { t } = useI18n();
const getters = useStoreGetters();
const editLiveChatCampaignDialogRef = ref(null);
const confirmDeleteCampaignDialogRef = ref(null);
const selectedCampaign = ref(null);
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const [showLiveChatCampaignDialog, toggleLiveChatCampaignDialog] = useToggle();
const liveChatCampaigns = computed(
() => getters['campaigns/getLiveChatCampaigns'].value
);
const hasNoLiveChatCampaigns = computed(
() => liveChatCampaigns.value?.length === 0 && !isFetchingCampaigns.value
);
const handleEdit = campaign => {
selectedCampaign.value = campaign;
editLiveChatCampaignDialogRef.value.dialogRef.open();
};
const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
</script>
<template>
<CampaignLayout
:header-title="t('CAMPAIGN.LIVE_CHAT.HEADER_TITLE')"
:button-label="t('CAMPAIGN.LIVE_CHAT.NEW_CAMPAIGN')"
@click="toggleLiveChatCampaignDialog()"
@close="toggleLiveChatCampaignDialog(false)"
>
<template #action>
<LiveChatCampaignDialog
v-if="showLiveChatCampaignDialog"
@close="toggleLiveChatCampaignDialog(false)"
/>
</template>
<div
v-if="isFetchingCampaigns"
class="flex justify-center items-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CampaignList
v-else-if="!hasNoLiveChatCampaigns"
:campaigns="liveChatCampaigns"
is-live-chat-type
@edit="handleEdit"
@delete="handleDelete"
/>
<LiveChatCampaignEmptyState
v-else
:title="t('CAMPAIGN.LIVE_CHAT.EMPTY_STATE.TITLE')"
:subtitle="t('CAMPAIGN.LIVE_CHAT.EMPTY_STATE.SUBTITLE')"
class="pt-14"
/>
<EditLiveChatCampaignDialog
ref="editLiveChatCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
<ConfirmDeleteCampaignDialog
ref="confirmDeleteCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
</CampaignLayout>
</template>

View File

@@ -1,72 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
import SMSCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import SMSCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/SMSCampaignEmptyState.vue';
const { t } = useI18n();
const getters = useStoreGetters();
const selectedCampaign = ref(null);
const [showSMSCampaignDialog, toggleSMSCampaignDialog] = useToggle();
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const confirmDeleteCampaignDialogRef = ref(null);
const SMSCampaigns = computed(() => getters['campaigns/getSMSCampaigns'].value);
const hasNoSMSCampaigns = computed(
() => SMSCampaigns.value?.length === 0 && !isFetchingCampaigns.value
);
const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
</script>
<template>
<CampaignLayout
:header-title="t('CAMPAIGN.SMS.HEADER_TITLE')"
:button-label="t('CAMPAIGN.SMS.NEW_CAMPAIGN')"
@click="toggleSMSCampaignDialog()"
@close="toggleSMSCampaignDialog(false)"
>
<template #action>
<SMSCampaignDialog
v-if="showSMSCampaignDialog"
@close="toggleSMSCampaignDialog(false)"
/>
</template>
<div
v-if="isFetchingCampaigns"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CampaignList
v-else-if="!hasNoSMSCampaigns"
:campaigns="SMSCampaigns"
@delete="handleDelete"
/>
<SMSCampaignEmptyState
v-else
:title="t('CAMPAIGN.SMS.EMPTY_STATE.TITLE')"
:subtitle="t('CAMPAIGN.SMS.EMPTY_STATE.SUBTITLE')"
class="pt-14"
/>
<ConfirmDeleteCampaignDialog
ref="confirmDeleteCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
</CampaignLayout>
</template>

View File

@@ -1,74 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
import WhatsAppCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import WhatsAppCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue';
const { t } = useI18n();
const getters = useStoreGetters();
const selectedCampaign = ref(null);
const [showWhatsAppCampaignDialog, toggleWhatsAppCampaignDialog] = useToggle();
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const confirmDeleteCampaignDialogRef = ref(null);
const WhatsAppCampaigns = computed(
() => getters['campaigns/getWhatsAppCampaigns'].value
);
const hasNoWhatsAppCampaigns = computed(
() => WhatsAppCampaigns.value?.length === 0 && !isFetchingCampaigns.value
);
const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
</script>
<template>
<CampaignLayout
:header-title="t('CAMPAIGN.WHATSAPP.HEADER_TITLE')"
:button-label="t('CAMPAIGN.WHATSAPP.NEW_CAMPAIGN')"
@click="toggleWhatsAppCampaignDialog()"
@close="toggleWhatsAppCampaignDialog(false)"
>
<template #action>
<WhatsAppCampaignDialog
v-if="showWhatsAppCampaignDialog"
@close="toggleWhatsAppCampaignDialog(false)"
/>
</template>
<div
v-if="isFetchingCampaigns"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CampaignList
v-else-if="!hasNoWhatsAppCampaigns"
:campaigns="WhatsAppCampaigns"
@delete="handleDelete"
/>
<WhatsAppCampaignEmptyState
v-else
:title="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.TITLE')"
:subtitle="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.SUBTITLE')"
class="pt-14"
/>
<ConfirmDeleteCampaignDialog
ref="confirmDeleteCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
</CampaignLayout>
</template>

View File

@@ -1,89 +0,0 @@
<script setup>
import { computed, nextTick, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const store = useStore();
const router = useRouter();
const { uiSettings } = useUISettings();
const route = useRoute();
const assistants = computed(
() => store.getters['captainAssistants/getRecords']
);
const isAssistantPresent = assistantId => {
return !!assistants.value.find(a => a.id === Number(assistantId));
};
const routeToView = (name, params) => {
router.replace({ name, params, replace: true });
};
const generateRouterParams = () => {
const { last_active_assistant_id: lastActiveAssistantId } =
uiSettings.value || {};
if (isAssistantPresent(lastActiveAssistantId)) {
return {
assistantId: lastActiveAssistantId,
};
}
if (assistants.value.length > 0) {
const { id: assistantId } = assistants.value[0];
return { assistantId };
}
return null;
};
const routeToLastActiveAssistant = () => {
const params = generateRouterParams();
// No assistants found, redirect to create page
if (!params) {
return routeToView('captain_assistants_create_index', {
accountId: route.params.accountId,
});
}
const { navigationPath } = route.params;
const isAValidRoute = [
'captain_assistants_responses_index', // Faq page
'captain_assistants_documents_index', // Document page
'captain_assistants_scenarios_index', // Scenario page
'captain_assistants_playground_index', // Playground page
'captain_assistants_inboxes_index', // Inboxes page
'captain_tools_index', // Tools page
'captain_assistants_settings_index', // Settings page
].includes(navigationPath);
const navigateTo = isAValidRoute
? navigationPath
: 'captain_assistants_responses_index';
return routeToView(navigateTo, {
accountId: route.params.accountId,
...params,
});
};
const performRouting = async () => {
await store.dispatch('captainAssistants/get');
nextTick(() => routeToLastActiveAssistant());
};
onMounted(() => performRouting());
</script>
<template>
<div
class="flex items-center justify-center w-full bg-n-surface-1 text-n-slate-11"
>
<Spinner />
</div>
</template>

View File

@@ -1,30 +0,0 @@
<script setup>
import { watch } from 'vue';
import { useRoute } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
watch(
() => route.params.assistantId,
newAssistantId => {
if (
newAssistantId &&
newAssistantId !== String(uiSettings.value.last_active_assistant_id)
) {
updateUISettings({
last_active_assistant_id: Number(newAssistantId),
});
}
}
);
</script>
<template>
<div class="flex w-full h-full min-h-0">
<section class="flex flex-1 h-full px-0 overflow-hidden bg-n-surface-1">
<router-view />
</section>
</div>
</template>

View File

@@ -1,171 +0,0 @@
<script setup>
import { ref, computed, onMounted, reactive } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { debounce } from '@chatwoot/utils';
import { useCompaniesStore } from 'dashboard/stores/companies';
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
const DEFAULT_SORT_FIELD = 'name';
const DEBOUNCE_DELAY = 300;
const companiesStore = useCompaniesStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { updateUISettings, uiSettings } = useUISettings();
const companies = computed(() => companiesStore.getCompaniesList);
const meta = computed(() => companiesStore.getMeta);
const uiFlags = computed(() => companiesStore.getUIFlags);
const searchQuery = computed(() => route.query?.search || '');
const searchValue = ref(searchQuery.value);
const pageNumber = computed(() => Number(route.query?.page) || 1);
const parseSortSettings = (sortString = '') => {
const hasDescending = sortString.startsWith('-');
const sortField = hasDescending ? sortString.slice(1) : sortString;
return {
sort: sortField || DEFAULT_SORT_FIELD,
order: hasDescending ? '-' : '',
};
};
const { companies_sort_by: companySortBy = DEFAULT_SORT_FIELD } =
uiSettings.value ?? {};
const { sort: initialSort, order: initialOrder } =
parseSortSettings(companySortBy);
const sortState = reactive({
activeSort: initialSort,
activeOrdering: initialOrder,
});
const activeSort = computed(() => sortState.activeSort);
const activeOrdering = computed(() => sortState.activeOrdering);
const isFetchingList = computed(() => uiFlags.value.fetchingList);
const buildSortAttr = () =>
`${sortState.activeOrdering}${sortState.activeSort}`;
const sortParam = computed(() => buildSortAttr());
const updateURLParams = (page, search = '', sort = '') => {
const query = {
...route.query,
page: page.toString(),
};
if (search) {
query.search = search;
} else {
delete query.search;
}
if (sort) {
query.sort = sort;
} else {
delete query.sort;
}
router.replace({ query });
};
const fetchCompanies = async (page, search, sort) => {
const currentPage = page ?? pageNumber.value;
const currentSearch = search ?? searchQuery.value;
const currentSort = sort ?? sortParam.value;
// Only update URL if arguments were explicitly provided
if (page !== undefined || search !== undefined || sort !== undefined) {
updateURLParams(currentPage, currentSearch, currentSort);
}
if (currentSearch) {
await companiesStore.search({
search: currentSearch,
page: currentPage,
sort: currentSort,
});
} else {
await companiesStore.get({
page: currentPage,
sort: currentSort,
});
}
};
const onSearch = debounce(query => {
searchValue.value = query;
fetchCompanies(1, query, sortParam.value);
}, DEBOUNCE_DELAY);
const onPageChange = page => {
fetchCompanies(page, searchValue.value, sortParam.value);
};
const handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
await updateUISettings({
companies_sort_by: buildSortAttr(),
});
fetchCompanies(1, searchValue.value, buildSortAttr());
};
onMounted(() => {
searchValue.value = searchQuery.value;
fetchCompanies();
});
</script>
<template>
<CompaniesListLayout
:search-value="searchValue"
:header-title="t('COMPANIES.HEADER')"
:current-page="pageNumber"
:total-items="Number(meta.totalCount || 0)"
:active-sort="activeSort"
:active-ordering="activeOrdering"
:is-fetching-list="isFetchingList"
:show-pagination-footer="!!companies.length"
@update:current-page="onPageChange"
@update:sort="handleSort"
@search="onSearch"
>
<div v-if="isFetchingList" class="flex items-center justify-center p-8">
<span class="text-n-slate-11 text-base">{{
t('COMPANIES.LOADING')
}}</span>
</div>
<div
v-else-if="companies.length === 0"
class="flex items-center justify-center p-8"
>
<span class="text-n-slate-11 text-base">{{
t('COMPANIES.EMPTY_STATE.TITLE')
}}</span>
</div>
<div v-else class="flex flex-col gap-4">
<CompaniesCard
v-for="company in companies"
:id="company.id"
:key="company.id"
:name="company.name"
:domain="company.domain"
:contacts-count="company.contactsCount || 0"
:description="company.description"
:avatar-url="company.avatarUrl"
:updated-at="company.updatedAt"
/>
</div>
</CompaniesListLayout>
</template>

View File

@@ -1,185 +0,0 @@
<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>

View File

@@ -1,516 +0,0 @@
<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>

View File

@@ -1,76 +0,0 @@
<script setup>
import { computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import UpgradePage from '../components/UpgradePage.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const accountId = computed(() => store.getters.getCurrentAccountId);
const portals = computed(() => store.getters['portals/allPortals']);
const isFeatureEnabledonAccount = (id, flag) =>
store.getters['accounts/isFeatureEnabledonAccount'](id, flag);
const isHelpCenterEnabled = computed(() =>
isFeatureEnabledonAccount(accountId.value, FEATURE_FLAGS.HELP_CENTER)
);
const selectedPortal = computed(() => {
const slug =
route.params.portalSlug || uiSettings.value.last_active_portal_slug;
if (slug) return store.getters['portals/portalBySlug'](slug);
return portals.value[0];
});
const defaultPortalLocale = computed(() =>
selectedPortal.value ? selectedPortal.value.meta?.default_locale : ''
);
const selectedLocaleInPortal = computed(
() => route.params.locale || defaultPortalLocale.value
);
const selectedPortalSlug = computed(() =>
selectedPortal.value ? selectedPortal.value.slug : ''
);
const fetchPortalAndItsCategories = async () => {
await store.dispatch('portals/index');
const selectedPortalParam = {
portalSlug: selectedPortalSlug.value,
locale: selectedLocaleInPortal.value,
};
store.dispatch('portals/show', selectedPortalParam);
store.dispatch('categories/index', selectedPortalParam);
store.dispatch('agents/get');
};
onMounted(() => fetchPortalAndItsCategories());
watch(
() => route.params.portalSlug,
newSlug => {
if (newSlug && newSlug !== uiSettings.value.last_active_portal_slug) {
updateUISettings({
last_active_portal_slug: newSlug,
last_active_locale_code: selectedLocaleInPortal.value,
});
}
}
);
</script>
<template>
<div class="flex w-full h-full min-h-0">
<section
v-if="isHelpCenterEnabled"
class="flex flex-1 h-full px-0 overflow-hidden bg-n-surface-1"
>
<router-view />
</section>
<UpgradePage v-else />
</div>
</template>

View File

@@ -1,119 +0,0 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { articleSlug, portalSlug } = route.params;
const articleById = useMapGetter('articles/articleById');
const article = computed(() => articleById.value(articleSlug));
const portalBySlug = useMapGetter('portals/portalBySlug');
const portal = computed(() => portalBySlug.value(portalSlug));
const isUpdating = ref(false);
const isSaved = ref(false);
const articleLink = computed(() => {
const { slug: categorySlug, locale: categoryLocale } = article.value.category;
const { slug: articleSlugValue } = article.value;
const portalCustomDomain = portal.value?.custom_domain;
return buildPortalArticleURL(
portalSlug,
categorySlug,
categoryLocale,
articleSlugValue,
portalCustomDomain
);
});
const saveArticle = async ({ ...values }, isAsync = false) => {
const actionToDispatch = isAsync ? 'articles/updateAsync' : 'articles/update';
isUpdating.value = true;
try {
await store.dispatch(actionToDispatch, {
portalSlug,
articleId: articleSlug,
...values,
});
isSaved.value = true;
} catch (error) {
const errorMessage =
error?.message || t('HELP_CENTER.EDIT_ARTICLE_PAGE.API.ERROR');
useAlert(errorMessage);
} finally {
setTimeout(() => {
isUpdating.value = false;
isSaved.value = true;
}, 1500);
}
};
const saveArticleAsync = async ({ ...values }) => {
saveArticle({ ...values }, true);
};
const isCategoryArticles = computed(() => {
return (
route.name === 'portals_categories_articles_index' ||
route.name === 'portals_categories_articles_edit' ||
route.name === 'portals_categories_index'
);
});
const goBackToArticles = () => {
const { tab, categorySlug, locale } = route.params;
if (isCategoryArticles.value) {
router.push({
name: 'portals_categories_articles_index',
params: { categorySlug, locale },
});
} else {
router.push({
name: 'portals_articles_index',
params: { tab, categorySlug, locale },
});
}
};
const fetchArticleDetails = () => {
store.dispatch('articles/show', {
id: articleSlug,
portalSlug,
});
};
const previewArticle = () => {
window.open(articleLink.value, '_blank');
useTrack(PORTALS_EVENTS.PREVIEW_ARTICLE, {
status: article.value?.status,
});
};
onMounted(fetchArticleDetails);
</script>
<template>
<ArticleEditor
:article="article"
:is-updating="isUpdating"
:is-saved="isSaved"
@save-article="saveArticle"
@save-article-async="saveArticleAsync"
@preview-article="previewArticle"
@go-back="goBackToArticles"
/>
</template>

View File

@@ -1,116 +0,0 @@
<script setup>
import { computed, ref, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import allLocales from 'shared/constants/locales.js';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import ArticlesPage from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue';
const route = useRoute();
const store = useStore();
const pageNumber = ref(1);
const articles = useMapGetter('articles/allArticles');
const categories = useMapGetter('categories/allCategories');
const meta = useMapGetter('articles/getMeta');
const portalMeta = useMapGetter('portals/getMeta');
const currentUserId = useMapGetter('getCurrentUserID');
const getPortalBySlug = useMapGetter('portals/portalBySlug');
const selectedPortalSlug = computed(() => route.params.portalSlug);
const selectedCategorySlug = computed(() => route.params.categorySlug);
const status = computed(() => getArticleStatus(route.params.tab));
const author = computed(() =>
route.params.tab === 'mine' ? currentUserId.value : null
);
const activeLocale = computed(() => route.params.locale);
const portal = computed(() => getPortalBySlug.value(selectedPortalSlug.value));
const allowedLocales = computed(() => {
if (!portal.value) {
return [];
}
const { allowed_locales: allAllowedLocales } = portal.value.config;
return allAllowedLocales.map(locale => {
return {
id: locale.code,
name: allLocales[locale.code],
code: locale.code,
};
});
});
const defaultPortalLocale = computed(() => {
return portal.value?.meta?.default_locale;
});
const selectedLocaleInPortal = computed(() => {
return route.params.locale || defaultPortalLocale.value;
});
const isCategoryArticles = computed(() => {
return (
route.name === 'portals_categories_articles_index' ||
route.name === 'portals_categories_articles_edit' ||
route.name === 'portals_categories_index'
);
});
const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
store.dispatch('articles/index', {
pageNumber: pageNumberParam || pageNumber.value,
portalSlug: selectedPortalSlug.value,
locale: activeLocale.value,
status: status.value,
authorId: author.value,
categorySlug: selectedCategorySlug.value,
});
};
const onPageChange = pageNumberParam => {
fetchArticles({ pageNumber: pageNumberParam });
};
const fetchPortalAndItsCategories = async locale => {
await store.dispatch('portals/index');
const selectedPortalParam = {
portalSlug: selectedPortalSlug.value,
locale: locale || selectedLocaleInPortal.value,
};
store.dispatch('portals/show', selectedPortalParam);
store.dispatch('categories/index', selectedPortalParam);
store.dispatch('agents/get');
};
onMounted(() => {
fetchArticles();
});
watch(
() => route.params,
() => {
pageNumber.value = 1;
fetchArticles();
},
{ deep: true, immediate: true }
);
</script>
<template>
<div class="w-full h-full">
<ArticlesPage
v-if="portal"
:articles="articles"
:portal-name="portal.name"
:categories="categories"
:allowed-locales="allowedLocales"
:meta="meta"
:portal-meta="portalMeta"
:is-category-articles="isCategoryArticles"
@page-change="onPageChange"
@fetch-portal="fetchPortalAndItsCategories"
/>
</div>
</template>

View File

@@ -1,94 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { portalSlug } = route.params;
const selectedAuthorId = ref(null);
const selectedCategoryId = ref(null);
const currentUserId = useMapGetter('getCurrentUserID');
const categories = useMapGetter('categories/allCategories');
const categoryId = computed(() => categories.value[0]?.id || null);
const article = ref({});
const isUpdating = ref(false);
const isSaved = ref(false);
const setAuthorId = authorId => {
selectedAuthorId.value = authorId;
};
const setCategoryId = newCategoryId => {
selectedCategoryId.value = newCategoryId;
};
const createNewArticle = async ({ title, content }) => {
if (title) article.value.title = title;
if (content) article.value.content = content;
if (!article.value.title || !article.value.content) return;
isUpdating.value = true;
try {
const { locale } = route.params;
const articleId = await store.dispatch('articles/create', {
portalSlug,
content: article.value.content,
title: article.value.title,
locale: locale,
authorId: selectedAuthorId.value || currentUserId.value,
categoryId: selectedCategoryId.value || categoryId.value,
});
useTrack(PORTALS_EVENTS.CREATE_ARTICLE, { locale });
router.replace({
name: 'portals_articles_edit',
params: {
articleSlug: articleId,
portalSlug,
locale,
},
});
} catch (error) {
const errorMessage =
error?.message || t('HELP_CENTER.EDIT_ARTICLE_PAGE.API.ERROR');
useAlert(errorMessage);
} finally {
isUpdating.value = false;
}
};
const goBackToArticles = () => {
const { tab, categorySlug, locale } = route.params;
router.push({
name: 'portals_articles_index',
params: { tab, categorySlug, locale },
});
};
</script>
<template>
<ArticleEditor
:article="article"
:is-updating="isUpdating"
:is-saved="isSaved"
@save-article="createNewArticle"
@go-back="goBackToArticles"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
</template>

View File

@@ -1,66 +0,0 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import allLocales from 'shared/constants/locales.js';
import CategoriesPage from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue';
const store = useStore();
const route = useRoute();
const categories = useMapGetter('categories/allCategories');
const selectedPortalSlug = computed(() => route.params.portalSlug);
const getPortalBySlug = useMapGetter('portals/portalBySlug');
const isFetching = useMapGetter('categories/isFetching');
const portal = computed(() => getPortalBySlug.value(selectedPortalSlug.value));
const allowedLocales = computed(() => {
if (!portal.value) {
return [];
}
const { allowed_locales: allAllowedLocales } = portal.value.config;
return allAllowedLocales.map(locale => {
return {
id: locale.code,
name: allLocales[locale.code],
code: locale.code,
};
});
});
const fetchCategoriesByPortalSlugAndLocale = async localeCode => {
await store.dispatch('categories/index', {
portalSlug: selectedPortalSlug.value,
locale: localeCode,
});
};
const updateMeta = async localeCode => {
return store.dispatch('portals/show', {
portalSlug: selectedPortalSlug.value,
locale: localeCode,
});
};
const fetchCategories = async localeCode => {
await fetchCategoriesByPortalSlugAndLocale(localeCode);
await updateMeta(localeCode);
};
onMounted(() => {
fetchCategoriesByPortalSlugAndLocale(route.params.locale);
});
</script>
<template>
<CategoriesPage
:categories="categories"
:is-fetching="isFetching"
:allowed-locales="allowedLocales"
@fetch-categories="fetchCategories"
/>
</template>

View File

@@ -1,76 +0,0 @@
<script setup>
import { computed, nextTick, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const store = useStore();
const router = useRouter();
const { uiSettings } = useUISettings();
const route = useRoute();
const portals = computed(() => store.getters['portals/allPortals']);
const isPortalPresent = portalSlug => {
return !!portals.value.find(portal => portal.slug === portalSlug);
};
const routeToView = (name, params) => {
router.replace({ name, params, replace: true });
};
const generateRouterParams = () => {
const {
last_active_portal_slug: lastActivePortalSlug,
last_active_locale_code: lastActiveLocaleCode,
} = uiSettings.value || {};
if (isPortalPresent(lastActivePortalSlug)) {
return {
portalSlug: lastActivePortalSlug,
locale: lastActiveLocaleCode,
};
}
if (portals.value.length > 0) {
const { slug: portalSlug, meta: { default_locale: locale } = {} } =
portals.value[0];
return { portalSlug, locale };
}
return null;
};
const routeToLastActivePortal = () => {
const params = generateRouterParams();
const { navigationPath } = route.params;
const isAValidRoute = [
'portals_articles_index',
'portals_categories_index',
'portals_locales_index',
'portals_settings_index',
].includes(navigationPath);
const navigateTo = isAValidRoute ? navigationPath : 'portals_articles_index';
if (params) {
return routeToView(navigateTo, params);
}
return routeToView('portals_new', {});
};
const performRouting = async () => {
await store.dispatch('portals/index');
nextTick(() => routeToLastActivePortal());
};
onMounted(() => performRouting());
</script>
<template>
<div
class="flex items-center justify-center w-full bg-n-surface-1 text-n-slate-11"
>
<Spinner />
</div>
</template>

View File

@@ -1,34 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
import allLocales from 'shared/constants/locales.js';
import LocalesPage from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocalesPage.vue';
const route = useRoute();
const getPortalBySlug = useMapGetter('portals/portalBySlug');
const portal = computed(() => getPortalBySlug.value(route.params.portalSlug));
const allowedLocales = computed(() => {
if (!portal.value) {
return [];
}
const { allowed_locales: allAllowedLocales } = portal.value.config;
return allAllowedLocales.map(locale => {
return {
id: locale?.code,
name: allLocales[locale?.code],
code: locale?.code,
articlesCount: locale?.articles_count || 0,
categoriesCount: locale?.categories_count || 0,
};
});
});
</script>
<template>
<LocalesPage :locales="allowedLocales" :portal="portal" />
</template>

View File

@@ -1,9 +0,0 @@
<script setup>
import PortalEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue';
</script>
<template>
<div class="w-full h-full bg-n-background">
<PortalEmptyState />
</div>
</template>

View File

@@ -1,164 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import PortalSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue';
const SSL_STATUS_FETCH_INTERVAL = 5000;
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const { isOnChatwootCloud } = useAccount();
const { updateUISettings } = useUISettings();
const portals = useMapGetter('portals/allPortals');
const isFetching = useMapGetter('portals/isFetchingPortals');
const getPortalBySlug = useMapGetter('portals/portalBySlug');
const getNextAvailablePortal = deletedPortalSlug =>
portals.value?.find(portal => portal.slug !== deletedPortalSlug) ?? null;
const getDefaultLocale = slug => {
return getPortalBySlug.value(slug)?.meta?.default_locale;
};
const fetchSSLStatus = () => {
if (!isOnChatwootCloud.value) return;
const { portalSlug } = route.params;
store.dispatch('portals/sslStatus', {
portalSlug,
});
};
const fetchPortalAndItsCategories = async (slug, locale) => {
const selectedPortalParam = { portalSlug: slug, locale };
await Promise.all([
store.dispatch('portals/index'),
store.dispatch('portals/show', selectedPortalParam),
store.dispatch('categories/index', selectedPortalParam),
store.dispatch('agents/get'),
store.dispatch('inboxes/get'),
]);
};
const updateRouteAfterDeletion = async deletedPortalSlug => {
const nextPortal = getNextAvailablePortal(deletedPortalSlug);
if (nextPortal) {
const {
slug,
meta: { default_locale: defaultLocale },
} = nextPortal;
await fetchPortalAndItsCategories(slug, defaultLocale);
router.push({
name: 'portals_articles_index',
params: { portalSlug: slug, locale: defaultLocale },
});
} else {
router.push({ name: 'portals_new' });
}
};
const refreshPortalRoute = async (newSlug, defaultLocale) => {
// This is to refresh the portal route and update the UI settings
// If there is slug change, this will be called to refresh the route and UI settings
await fetchPortalAndItsCategories(newSlug, defaultLocale);
updateUISettings({
last_active_portal_slug: newSlug,
last_active_locale_code: defaultLocale,
});
await router.replace({
name: 'portals_settings_index',
params: { portalSlug: newSlug },
});
};
const updatePortalSettings = async portalObj => {
const { portalSlug } = route.params;
try {
const defaultLocale = getDefaultLocale(portalSlug);
await store.dispatch('portals/update', {
...portalObj,
portalSlug: portalSlug || portalObj?.slug,
});
// If there is a slug change, this will refresh the route and update the UI settings
if (portalObj?.slug && portalSlug !== portalObj.slug) {
await refreshPortalRoute(portalObj.slug, defaultLocale);
}
useAlert(
t('HELP_CENTER.PORTAL_SETTINGS.API.UPDATE_PORTAL.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL_SETTINGS.API.UPDATE_PORTAL.ERROR_MESSAGE')
);
}
};
const deletePortal = async selectedPortalForDelete => {
const { slug } = selectedPortalForDelete;
try {
await store.dispatch('portals/delete', { portalSlug: slug });
await updateRouteAfterDeletion(slug);
useAlert(
t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.API.DELETE_SUCCESS')
);
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.API.DELETE_ERROR')
);
}
};
const handleSendCnameInstructions = async payload => {
try {
await store.dispatch('portals/sendCnameInstructions', payload);
useAlert(
t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.SEND_CNAME_INSTRUCTIONS.API.SUCCESS_MESSAGE'
)
);
} catch (error) {
useAlert(
error?.message ||
t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.SEND_CNAME_INSTRUCTIONS.API.ERROR_MESSAGE'
)
);
}
};
const handleUpdatePortal = updatePortalSettings;
const handleUpdatePortalConfiguration = portalObj => {
updatePortalSettings(portalObj);
// If custom domain is added or updated, fetch SSL status after a delay of 5 seconds (only on Chatwoot cloud)
if (portalObj?.custom_domain && isOnChatwootCloud.value) {
setTimeout(() => {
fetchSSLStatus();
}, SSL_STATUS_FETCH_INTERVAL);
}
};
const handleDeletePortal = deletePortal;
</script>
<template>
<PortalSettings
:portals="portals"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
@update-portal-configuration="handleUpdatePortalConfiguration"
@delete-portal="handleDeletePortal"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
</template>

View File

@@ -1,107 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const formRef = ref(null);
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const inboxIdFromQuery = computed(() => {
const id = route.query.inboxId;
return id ? Number(id) : null;
});
const breadcrumbItems = computed(() => {
if (inboxIdFromQuery.value) {
return [
{
label: t('INBOX_MGMT.SETTINGS'),
routeName: 'settings_inbox_show',
params: { inboxId: inboxIdFromQuery.value },
},
{
label: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'
),
},
];
}
return [
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
routeName: 'agent_assignment_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
},
];
});
const handleBreadcrumbClick = item => {
if (item.params) {
const accountId = route.params.accountId;
const inboxId = item.params.inboxId;
// Navigate using explicit path to ensure tab parameter is included
router.push(
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
);
} else {
router.push({
name: item.routeName,
});
}
};
const handleSubmit = async formState => {
try {
const policy = await store.dispatch('assignmentPolicies/create', formState);
useAlert(
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.API.SUCCESS_MESSAGE')
);
formRef.value?.resetForm();
router.push({
name: 'agent_assignment_policy_edit',
params: {
id: policy.id,
},
// Pass inboxId to edit page to show link prompt
query: inboxIdFromQuery.value ? { inboxId: inboxIdFromQuery.value } : {},
});
} catch (error) {
useAlert(
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.API.ERROR_MESSAGE')
);
}
};
</script>
<template>
<SettingsLayout class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
<template #header>
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
<template #body>
<AssignmentPolicyForm
ref="formRef"
mode="CREATE"
:is-loading="uiFlags.isCreating"
@submit="handleSubmit"
/>
</template>
</SettingsLayout>
</template>

View File

@@ -1,293 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import {
ROUND_ROBIN,
EARLIEST_CREATED,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
import ConfirmInboxDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue';
import InboxLinkDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/InboxLinkDialog.vue';
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const inboxes = useMapGetter('inboxes/getAllInboxes');
const inboxUiFlags = useMapGetter('assignmentPolicies/getInboxUiFlags');
const selectedPolicyById = useMapGetter(
'assignmentPolicies/getAssignmentPolicyById'
);
const routeId = computed(() => route.params.id);
const selectedPolicy = computed(() => selectedPolicyById.value(routeId.value));
const confirmInboxDialogRef = ref(null);
// Store the policy linked to the inbox when adding a new inbox
const inboxLinkedPolicy = ref(null);
// Inbox linking prompt from create flow
const inboxIdFromQuery = computed(() => {
const id = route.query.inboxId;
return id ? Number(id) : null;
});
const suggestedInbox = computed(() => {
if (!inboxIdFromQuery.value || !inboxes.value) return null;
return inboxes.value.find(inbox => inbox.id === inboxIdFromQuery.value);
});
const isLinkingInbox = ref(false);
const dismissInboxLinkPrompt = () => {
router.replace({
name: route.name,
params: route.params,
query: {},
});
};
const breadcrumbItems = computed(() => {
if (inboxIdFromQuery.value) {
return [
{
label: t('INBOX_MGMT.SETTINGS'),
routeName: 'settings_inbox_show',
params: { inboxId: inboxIdFromQuery.value },
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
];
}
return [
{
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
routeName: 'agent_assignment_policy_index',
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
];
});
const buildInboxList = allInboxes =>
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || [];
const policyInboxes = computed(() =>
buildInboxList(selectedPolicy.value?.inboxes)
);
const inboxList = computed(() =>
buildInboxList(
inboxes.value?.slice().sort((a, b) => a.name.localeCompare(b.name))
)
);
const formData = computed(() => ({
name: selectedPolicy.value?.name || '',
description: selectedPolicy.value?.description || '',
enabled: true,
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
conversationPriority:
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
}));
const handleDeleteInbox = async inboxId => {
try {
await store.dispatch('assignmentPolicies/removeInboxPolicy', {
policyId: selectedPolicy.value?.id,
inboxId,
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.ERROR_MESSAGE`));
}
};
const handleBreadcrumbClick = ({ routeName, params }) => {
if (params) {
const accountId = route.params.accountId;
const inboxId = params.inboxId;
// Navigate using explicit path to ensure tab parameter is included
router.push(
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
);
} else {
router.push({ name: routeName });
}
};
const handleNavigateToInbox = inbox => {
router.push({
name: 'settings_inbox_show',
params: {
accountId: route.params.accountId,
inboxId: inbox.id,
},
});
};
const setInboxPolicy = async (inboxId, policyId) => {
try {
await store.dispatch('assignmentPolicies/setInboxPolicy', {
inboxId,
policyId,
});
useAlert(t(`${BASE_KEY}.FORM.INBOXES.API.SUCCESS_MESSAGE`));
await store.dispatch(
'assignmentPolicies/getInboxes',
Number(routeId.value)
);
return true;
} catch (error) {
useAlert(t(`${BASE_KEY}.FORM.INBOXES.API.ERROR_MESSAGE`));
return false;
}
};
const handleAddInbox = async inbox => {
try {
const policy = await store.dispatch('assignmentPolicies/getInboxPolicy', {
inboxId: inbox?.id,
});
if (policy?.id !== selectedPolicy.value?.id) {
inboxLinkedPolicy.value = {
...policy,
assignedInboxCount: policy.assignedInboxCount - 1,
};
confirmInboxDialogRef.value.openDialog(inbox);
return;
}
} catch (error) {
// If getInboxPolicy fails, continue to setInboxPolicy
}
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id);
};
const handleLinkSuggestedInbox = async () => {
if (!suggestedInbox.value) return;
isLinkingInbox.value = true;
const inbox = {
id: suggestedInbox.value.id,
name: suggestedInbox.value.name,
};
await handleAddInbox(inbox);
// Clear the query param after linking
router.replace({
name: route.name,
params: route.params,
query: {},
});
isLinkingInbox.value = false;
};
const handleConfirmAddInbox = async inboxId => {
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
if (success) {
// Update the policy to reflect the assigned inbox count change
await store.dispatch('assignmentPolicies/updateInboxPolicy', {
policy: inboxLinkedPolicy.value,
});
// Fetch the updated inboxes for the policy after update, to reflect real-time changes
store.dispatch(
'assignmentPolicies/getInboxes',
inboxLinkedPolicy.value?.id
);
inboxLinkedPolicy.value = null;
confirmInboxDialogRef.value.closeDialog();
}
};
const handleSubmit = async formState => {
try {
await store.dispatch('assignmentPolicies/update', {
id: selectedPolicy.value?.id,
...formState,
});
useAlert(t(`${BASE_KEY}.EDIT.API.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.API.ERROR_MESSAGE`));
}
};
const fetchPolicyData = async () => {
if (!routeId.value) return;
// Fetch inboxes if not already loaded (needed for inbox link prompt)
if (!inboxes.value?.length) {
store.dispatch('inboxes/get');
}
// Fetch policy if not available
if (!selectedPolicy.value?.id)
await store.dispatch('assignmentPolicies/show', routeId.value);
await store.dispatch('assignmentPolicies/getInboxes', Number(routeId.value));
};
watch(routeId, fetchPolicyData, { immediate: true });
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetchingItem"
class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
<template #body>
<AssignmentPolicyForm
:key="routeId"
mode="EDIT"
:initial-data="formData"
:policy-inboxes="policyInboxes"
:inbox-list="inboxList"
show-inbox-section
:is-loading="uiFlags.isUpdating"
:is-inbox-loading="inboxUiFlags.isFetching"
@submit="handleSubmit"
@add-inbox="handleAddInbox"
@delete-inbox="handleDeleteInbox"
@navigate-to-inbox="handleNavigateToInbox"
/>
</template>
<ConfirmInboxDialog
ref="confirmInboxDialogRef"
@add="handleConfirmAddInbox"
/>
<InboxLinkDialog
:inbox="suggestedInbox"
:is-linking="isLinkingInbox"
@link="handleLinkSuggestedInbox"
@dismiss="dismissInboxLinkPrompt"
/>
</SettingsLayout>
</template>

View File

@@ -1,128 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyCard from 'dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue';
import ConfirmDeletePolicyDialog from './components/ConfirmDeletePolicyDialog.vue';
const store = useStore();
const { t } = useI18n();
const router = useRouter();
const agentAssignmentsPolicies = useMapGetter(
'assignmentPolicies/getAssignmentPolicies'
);
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const inboxUiFlags = useMapGetter('assignmentPolicies/getInboxUiFlags');
const confirmDeletePolicyDialogRef = ref(null);
const breadcrumbItems = computed(() => {
const items = [
{
label: t('ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
routeName: 'assignment_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
},
];
return items;
});
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
const onClickCreatePolicy = () => {
router.push({
name: 'agent_assignment_policy_create',
});
};
const onClickEditPolicy = id => {
router.push({
name: 'agent_assignment_policy_edit',
params: {
id,
},
});
};
const handleFetchInboxes = id => {
if (inboxUiFlags.value.isFetching) return;
store.dispatch('assignmentPolicies/getInboxes', id);
};
const handleDelete = id => {
confirmDeletePolicyDialogRef.value.openDialog(id);
};
const handleDeletePolicy = async policyId => {
try {
await store.dispatch('assignmentPolicies/delete', policyId);
useAlert(
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.DELETE_POLICY.SUCCESS_MESSAGE'
)
);
confirmDeletePolicyDialogRef.value.closeDialog();
} catch (error) {
useAlert(
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.DELETE_POLICY.ERROR_MESSAGE')
);
}
};
onMounted(() => {
store.dispatch('assignmentPolicies/get');
});
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetching"
:no-records-found="agentAssignmentsPolicies.length === 0"
:no-records-message="
$t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.NO_RECORDS_FOUND')
"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
<Button icon="i-lucide-plus" md @click="onClickCreatePolicy">
{{
$t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.CREATE_POLICY'
)
}}
</Button>
</div>
</template>
<template #body>
<div class="flex flex-col gap-4 pt-4">
<AssignmentPolicyCard
v-for="policy in agentAssignmentsPolicies"
:key="policy.id"
v-bind="policy"
:is-fetching-inboxes="inboxUiFlags.isFetching"
@fetch-inboxes="handleFetchInboxes"
@edit="onClickEditPolicy"
@delete="handleDelete"
/>
</div>
</template>
<ConfirmDeletePolicyDialog
ref="confirmDeletePolicyDialogRef"
@delete="handleDeletePolicy"
/>
</SettingsLayout>
</template>

View File

@@ -1,87 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AgentCapacityPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue';
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const formRef = ref(null);
const uiFlags = useMapGetter('agentCapacityPolicies/getUIFlags');
const labelsList = useMapGetter('labels/getLabels');
const allLabels = computed(() =>
labelsList.value?.map(({ title, color, id }) => ({
id,
name: title,
color,
}))
);
const breadcrumbItems = computed(() => [
{
label: t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.HEADER.TITLE'),
routeName: 'agent_capacity_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.CREATE.HEADER.TITLE'),
},
]);
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
const handleSubmit = async formState => {
try {
const policy = await store.dispatch(
'agentCapacityPolicies/create',
formState
);
useAlert(
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.CREATE.API.SUCCESS_MESSAGE')
);
formRef.value?.resetForm();
router.push({
name: 'agent_capacity_policy_edit',
params: {
id: policy.id,
},
});
} catch (error) {
useAlert(
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.CREATE.API.ERROR_MESSAGE')
);
}
};
</script>
<template>
<SettingsLayout class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
<template #header>
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
<template #body>
<AgentCapacityPolicyForm
ref="formRef"
mode="CREATE"
:is-loading="uiFlags.isCreating"
:label-list="allLabels"
@submit="handleSubmit"
/>
</template>
</SettingsLayout>
</template>

View File

@@ -1,220 +0,0 @@
<script setup>
import { computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import camelcaseKeys from 'camelcase-keys';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AgentCapacityPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue';
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const uiFlags = useMapGetter('agentCapacityPolicies/getUIFlags');
const usersUiFlags = useMapGetter('agentCapacityPolicies/getUsersUIFlags');
const selectedPolicyById = useMapGetter(
'agentCapacityPolicies/getAgentCapacityPolicyById'
);
const agentsList = useMapGetter('agents/getAgents');
const labelsList = useMapGetter('labels/getLabels');
const inboxes = useMapGetter('inboxes/getAllInboxes');
const inboxesUiFlags = useMapGetter('inboxes/getUIFlags');
const routeId = computed(() => route.params.id);
const selectedPolicy = computed(() => selectedPolicyById.value(routeId.value));
const selectedPolicyId = computed(() => selectedPolicy.value?.id);
const breadcrumbItems = computed(() => [
{
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
routeName: 'agent_capacity_policy_index',
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
]);
const buildList = items =>
items?.map(({ name, title, id, email, avatarUrl, thumbnail, color }) => ({
name: name || title,
id,
email,
avatarUrl: avatarUrl || thumbnail,
color,
})) || [];
const policyUsers = computed(() => buildList(selectedPolicy.value?.users));
const allAgents = computed(() =>
buildList(camelcaseKeys(agentsList.value)).filter(
agent => !policyUsers.value?.some(user => user.id === agent.id)
)
);
const allLabels = computed(() => buildList(labelsList.value));
const allInboxes = computed(
() =>
inboxes.value
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || []
);
const formData = computed(() => ({
name: selectedPolicy.value?.name || '',
description: selectedPolicy.value?.description || '',
exclusionRules: {
excludedLabels: [
...(selectedPolicy.value?.exclusionRules?.excludedLabels || []),
],
excludeOlderThanHours:
selectedPolicy.value?.exclusionRules?.excludeOlderThanHours || 10,
},
inboxCapacityLimits:
selectedPolicy.value?.inboxCapacityLimits?.map(limit => ({
...limit,
})) || [],
}));
const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName });
const handleDeleteUser = async agentId => {
try {
await store.dispatch('agentCapacityPolicies/removeUser', {
policyId: selectedPolicyId.value,
userId: agentId,
});
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.ERROR_MESSAGE`));
}
};
const handleAddUser = async agent => {
try {
await store.dispatch('agentCapacityPolicies/addUser', {
policyId: selectedPolicyId.value,
userData: { id: agent.id, capacity: 20 },
});
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.ERROR_MESSAGE`));
}
};
const handleDeleteInboxLimit = async limitId => {
try {
await store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
policyId: selectedPolicyId.value,
limitId,
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.ERROR_MESSAGE`));
}
};
const handleAddInboxLimit = async limit => {
try {
await store.dispatch('agentCapacityPolicies/createInboxLimit', {
policyId: selectedPolicyId.value,
limitData: {
inboxId: limit.inboxId,
conversationLimit: limit.conversationLimit,
},
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.ERROR_MESSAGE`));
}
};
const handleLimitChange = async limit => {
try {
await store.dispatch('agentCapacityPolicies/updateInboxLimit', {
policyId: selectedPolicyId.value,
limitId: limit.id,
limitData: { conversationLimit: limit.conversationLimit },
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.ERROR_MESSAGE`));
}
};
const handleSubmit = async formState => {
try {
await store.dispatch('agentCapacityPolicies/update', {
id: selectedPolicyId.value,
...formState,
});
useAlert(t(`${BASE_KEY}.EDIT.API.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.API.ERROR_MESSAGE`));
}
};
const fetchPolicyData = async () => {
if (!routeId.value) return;
// Fetch policy if not available
if (!selectedPolicyId.value)
await store.dispatch('agentCapacityPolicies/show', routeId.value);
await store.dispatch('agentCapacityPolicies/getUsers', Number(routeId.value));
};
watch(routeId, fetchPolicyData, { immediate: true });
onMounted(() => store.dispatch('agents/get'));
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetchingItem"
class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
<template #body>
<AgentCapacityPolicyForm
:key="routeId"
mode="EDIT"
:initial-data="formData"
:policy-users="policyUsers"
:agent-list="allAgents"
:label-list="allLabels"
:inbox-list="allInboxes"
show-user-section
show-inbox-limit-section
:is-loading="uiFlags.isUpdating"
:is-users-loading="usersUiFlags.isFetching"
:is-inboxes-loading="inboxesUiFlags.isFetching"
@submit="handleSubmit"
@add-user="handleAddUser"
@delete-user="handleDeleteUser"
@add-inbox-limit="handleAddInboxLimit"
@update-inbox-limit="handleLimitChange"
@delete-inbox-limit="handleDeleteInboxLimit"
/>
</template>
</SettingsLayout>
</template>

View File

@@ -1,126 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AgentCapacityPolicyCard from 'dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.vue';
import ConfirmDeletePolicyDialog from './components/ConfirmDeletePolicyDialog.vue';
const store = useStore();
const { t } = useI18n();
const router = useRouter();
const agentCapacityPolicies = useMapGetter(
'agentCapacityPolicies/getAgentCapacityPolicies'
);
const uiFlags = useMapGetter('agentCapacityPolicies/getUIFlags');
const usersUiFlags = useMapGetter('agentCapacityPolicies/getUsersUIFlags');
const confirmDeletePolicyDialogRef = ref(null);
const breadcrumbItems = computed(() => {
const items = [
{
label: t('ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
routeName: 'assignment_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.HEADER.TITLE'),
},
];
return items;
});
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
const onClickCreatePolicy = () => {
router.push({
name: 'agent_capacity_policy_create',
});
};
const onClickEditPolicy = id => {
router.push({
name: 'agent_capacity_policy_edit',
params: {
id,
},
});
};
const handleFetchUsers = id => {
if (usersUiFlags.value.isFetching) return;
store.dispatch('agentCapacityPolicies/getUsers', id);
};
const handleDelete = id => {
confirmDeletePolicyDialogRef.value.openDialog(id);
};
const handleDeletePolicy = async policyId => {
try {
await store.dispatch('agentCapacityPolicies/delete', policyId);
useAlert(
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.DELETE_POLICY.SUCCESS_MESSAGE')
);
confirmDeletePolicyDialogRef.value.closeDialog();
} catch (error) {
useAlert(
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.DELETE_POLICY.ERROR_MESSAGE')
);
}
};
onMounted(() => {
store.dispatch('agentCapacityPolicies/get');
});
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetching"
:no-records-found="agentCapacityPolicies.length === 0"
:no-records-message="
$t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.NO_RECORDS_FOUND')
"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
<Button icon="i-lucide-plus" md @click="onClickCreatePolicy">
{{
$t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.HEADER.CREATE_POLICY'
)
}}
</Button>
</div>
</template>
<template #body>
<div class="flex flex-col gap-4 pt-4">
<AgentCapacityPolicyCard
v-for="policy in agentCapacityPolicies"
:key="policy.id"
v-bind="policy"
:is-fetching-users="usersUiFlags.isFetching"
@fetch-users="handleFetchUsers"
@edit="onClickEditPolicy"
@delete="handleDelete"
/>
</div>
</template>
<ConfirmDeletePolicyDialog
ref="confirmDeletePolicyDialogRef"
@delete="handleDeletePolicy"
/>
</SettingsLayout>
</template>

View File

@@ -1,290 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
OPTIONS,
ROUND_ROBIN,
EARLIEST_CREATED,
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
const props = defineProps({
initialData: {
type: Object,
default: () => ({
name: '',
description: '',
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
}),
},
mode: {
type: String,
required: true,
validator: value => ['CREATE', 'EDIT'].includes(value),
},
policyInboxes: {
type: Array,
default: () => [],
},
inboxList: {
type: Array,
default: () => [],
},
showInboxSection: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
isInboxLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'submit',
'addInbox',
'deleteInbox',
'navigateToInbox',
'validationChange',
]);
const { t } = useI18n();
const route = useRoute();
const accountId = computed(() => Number(route.params.accountId));
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const state = reactive({
name: '',
description: '',
enabled: true,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
});
const validationState = ref({
isValid: false,
});
const createOption = (
type,
key,
stateKey,
disabled = false,
disabledMessage = '',
disabledLabel = ''
) => ({
key,
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
isActive: state[stateKey] === key,
disabled,
disabledMessage,
disabledLabel,
});
const assignmentOrderOptions = computed(() => {
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
accountId.value,
'advanced_assignment'
);
return OPTIONS.ORDER.map(key => {
const isBalanced = key === 'balanced';
const disabled = isBalanced && !hasAdvancedAssignment;
const disabledMessage = disabled
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_MESSAGE`)
: '';
const disabledLabel = disabled
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE`)
: '';
return createOption(
'ASSIGNMENT_ORDER',
key,
'assignmentOrder',
disabled,
disabledMessage,
disabledLabel
);
});
});
const assignmentPriorityOptions = computed(() =>
OPTIONS.PRIORITY.map(key =>
createOption('ASSIGNMENT_PRIORITY', key, 'conversationPriority')
)
);
const radioSections = computed(() => [
{
key: 'assignmentOrder',
label: t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.LABEL`),
options: assignmentOrderOptions.value,
},
{
key: 'conversationPriority',
label: t(`${BASE_KEY}.FORM.ASSIGNMENT_PRIORITY.LABEL`),
options: assignmentPriorityOptions.value,
},
]);
const buttonLabel = computed(() =>
t(`${BASE_KEY}.${props.mode.toUpperCase()}.${props.mode}_BUTTON`)
);
const handleValidationChange = validation => {
validationState.value = validation;
emit('validationChange', validation);
};
const resetForm = () => {
Object.assign(state, {
name: '',
description: '',
enabled: true,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
});
};
const handleSubmit = () => {
emit('submit', { ...state });
};
watch(
() => props.initialData,
newData => {
Object.assign(state, newData);
},
{ immediate: true, deep: true }
);
defineExpose({
resetForm,
});
</script>
<template>
<form @submit.prevent="handleSubmit">
<div class="flex flex-col gap-4 divide-y divide-n-weak mb-4">
<BaseInfo
v-model:policy-name="state.name"
v-model:description="state.description"
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
@validation-change="handleValidationChange"
/>
<div class="flex flex-col items-center">
<div
v-for="section in radioSections"
:key="section.key"
class="py-4 flex flex-col items-start gap-3 w-full"
>
<WithLabel
:label="section.label"
name="assignmentPolicy"
class="w-full flex items-start flex-col gap-3"
>
<div class="grid grid-cols-1 xs:grid-cols-2 gap-4 w-full">
<RadioCard
v-for="option in section.options"
:id="option.key"
:key="option.key"
:label="option.label"
:description="option.description"
:is-active="option.isActive"
:disabled="option.disabled"
:disabled-label="option.disabledLabel"
:disabled-message="option.disabledMessage"
@select="state[section.key] = $event"
/>
</div>
</WithLabel>
</div>
</div>
<div class="pt-4 pb-2 flex-col flex gap-4">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.FAIR_DISTRIBUTION.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.FAIR_DISTRIBUTION.DESCRIPTION`) }}
</p>
</div>
<FairDistribution
v-model:fair-distribution-limit="state.fairDistributionLimit"
v-model:fair-distribution-window="state.fairDistributionWindow"
v-model:window-unit="state.windowUnit"
/>
</div>
</div>
<Button
type="submit"
:label="buttonLabel"
:disabled="!validationState.isValid || isLoading"
:is-loading="isLoading"
/>
<div
v-if="showInboxSection"
class="py-4 flex-col flex gap-4 border-t border-n-weak mt-6"
>
<div class="flex items-end gap-4 w-full justify-between">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.INBOXES.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.INBOXES.DESCRIPTION`) }}
</p>
</div>
<AddDataDropdown
:label="t(`${BASE_KEY}.FORM.INBOXES.ADD_BUTTON`)"
:search-placeholder="
t(`${BASE_KEY}.FORM.INBOXES.DROPDOWN.SEARCH_PLACEHOLDER`)
"
:items="inboxList"
@add="$emit('addInbox', $event)"
/>
</div>
<DataTable
:items="policyInboxes"
:is-fetching="isInboxLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
@delete="$emit('deleteInbox', $event)"
@navigate="$emit('navigateToInbox', $event)"
/>
</div>
</form>
</template>

View File

@@ -1,214 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ExclusionRules from 'dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue';
import InboxCapacityLimits from 'dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue';
const props = defineProps({
initialData: {
type: Object,
default: () => ({
name: '',
description: '',
enabled: false,
exclusionRules: {
excludedLabels: [],
excludeOlderThanHours: 10,
},
inboxCapacityLimits: [],
}),
},
mode: {
type: String,
required: true,
validator: value => ['CREATE', 'EDIT'].includes(value),
},
policyUsers: {
type: Array,
default: () => [],
},
agentList: {
type: Array,
default: () => [],
},
labelList: {
type: Array,
default: () => [],
},
inboxList: {
type: Array,
default: () => [],
},
showUserSection: {
type: Boolean,
default: false,
},
showInboxLimitSection: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
isUsersLoading: {
type: Boolean,
default: false,
},
isInboxesLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'submit',
'addUser',
'deleteUser',
'validationChange',
'deleteInboxLimit',
'addInboxLimit',
'updateInboxLimit',
]);
const { t } = useI18n();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const state = reactive({
name: '',
description: '',
exclusionRules: {
excludedLabels: [],
excludeOlderThanHours: 10,
},
inboxCapacityLimits: [],
});
const validationState = ref({
isValid: false,
});
const buttonLabel = computed(() =>
t(`${BASE_KEY}.${props.mode.toUpperCase()}.${props.mode}_BUTTON`)
);
const handleValidationChange = validation => {
validationState.value = validation;
emit('validationChange', validation);
};
const handleDeleteInboxLimit = id => {
emit('deleteInboxLimit', id);
};
const handleAddInboxLimit = limit => {
emit('addInboxLimit', limit);
};
const handleLimitChange = limit => {
emit('updateInboxLimit', limit);
};
const resetForm = () => {
Object.assign(state, {
name: '',
description: '',
exclusionRules: {
excludedLabels: [],
excludeOlderThanHours: 10,
},
inboxCapacityLimits: [],
});
};
const handleSubmit = () => {
emit('submit', { ...state });
};
watch(
() => props.initialData,
newData => {
Object.assign(state, newData);
},
{ immediate: true, deep: true }
);
defineExpose({
resetForm,
});
</script>
<template>
<form @submit.prevent="handleSubmit">
<div class="flex flex-col gap-4 mb-2 divide-y divide-n-weak">
<BaseInfo
v-model:policy-name="state.name"
v-model:description="state.description"
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
@validation-change="handleValidationChange"
/>
<ExclusionRules
v-model:excluded-labels="state.exclusionRules.excludedLabels"
v-model:exclude-older-than-minutes="
state.exclusionRules.excludeOlderThanHours
"
:tags-list="labelList"
/>
</div>
<Button
type="submit"
:label="buttonLabel"
:disabled="!validationState.isValid || isLoading"
:is-loading="isLoading"
/>
<div
v-if="showInboxLimitSection || showUserSection"
class="flex flex-col gap-4 divide-y divide-n-weak border-t border-n-weak mt-6"
>
<InboxCapacityLimits
v-if="showInboxLimitSection"
v-model:inbox-capacity-limits="state.inboxCapacityLimits"
:inbox-list="inboxList"
:is-fetching="isInboxesLoading"
@delete="handleDeleteInboxLimit"
@add="handleAddInboxLimit"
@update="handleLimitChange"
/>
<div v-if="showUserSection" class="py-4 flex-col flex gap-4">
<div class="flex items-end gap-4 w-full justify-between">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.USERS.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.USERS.DESCRIPTION`) }}
</p>
</div>
<AddDataDropdown
:label="t(`${BASE_KEY}.FORM.USERS.ADD_BUTTON`)"
:search-placeholder="
t(`${BASE_KEY}.FORM.USERS.DROPDOWN.SEARCH_PLACEHOLDER`)
"
:items="agentList"
@add="$emit('addUser', $event)"
/>
</div>
<DataTable
:items="policyUsers"
:is-fetching="isUsersLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.USERS.EMPTY_STATE`)"
@delete="$emit('deleteUser', $event)"
/>
</div>
</div>
</form>
</template>

View File

@@ -1,44 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['delete']);
const { t } = useI18n();
const dialogRef = ref(null);
const currentPolicyId = ref(null);
const openDialog = policyId => {
currentPolicyId.value = policyId;
dialogRef.value.open();
};
const closeDialog = () => {
dialogRef.value.close();
};
const handleDialogConfirm = () => {
emit('delete', currentPolicyId.value);
};
defineExpose({ openDialog, closeDialog });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('ASSIGNMENT_POLICY.DELETE_POLICY.TITLE')"
:description="t('ASSIGNMENT_POLICY.DELETE_POLICY.DESCRIPTION')"
:confirm-button-label="
t('ASSIGNMENT_POLICY.DELETE_POLICY.CONFIRM_BUTTON_LABEL')
"
:cancel-button-label="
t('ASSIGNMENT_POLICY.DELETE_POLICY.CANCEL_BUTTON_LABEL')
"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -1,59 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['add']);
const { t } = useI18n();
const dialogRef = ref(null);
const currentInbox = ref(null);
const openDialog = inbox => {
currentInbox.value = inbox;
dialogRef.value.open();
};
const closeDialog = () => {
dialogRef.value.close();
};
const handleDialogConfirm = () => {
emit('add', currentInbox.value.id);
};
defineExpose({ openDialog, closeDialog });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.TITLE'
)
"
:description="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.DESCRIPTION',
{
inboxName: currentInbox?.name,
}
)
"
:confirm-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.CONFIRM_BUTTON_LABEL'
)
"
:cancel-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.CANCEL_BUTTON_LABEL'
)
"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -1,116 +0,0 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
inbox: {
type: Object,
default: null,
},
isLinking: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['link', 'dismiss']);
const { t } = useI18n();
const dialogRef = ref(null);
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
if (!props.inbox) return 'i-lucide-inbox';
return getInboxIconByType(
props.inbox.channelType,
props.inbox.medium,
'line'
);
});
const openDialog = () => {
dialogRef.value?.open();
};
const closeDialog = () => {
dialogRef.value?.close();
};
const handleConfirm = () => {
emit('link');
};
const handleClose = () => {
emit('dismiss');
};
watch(
() => props.inbox,
async newInbox => {
if (newInbox) {
await nextTick();
openDialog();
} else {
closeDialog();
}
},
{ immediate: true }
);
defineExpose({ openDialog, closeDialog });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.TITLE'
)
"
:confirm-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.LINK_BUTTON'
)
"
:cancel-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.CANCEL_BUTTON'
)
"
:is-loading="isLinking"
@confirm="handleConfirm"
@close="handleClose"
>
<template #description>
<p class="text-sm text-n-slate-11">
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.DESCRIPTION'
)
}}
</p>
</template>
<div
class="flex items-center gap-3 p-3 rounded-xl border border-n-weak bg-n-alpha-1"
>
<div
class="flex-shrink-0 size-10 rounded-lg bg-n-alpha-2 flex items-center justify-center"
>
<i :class="inboxIcon" class="text-lg text-n-slate-11" />
</div>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-n-slate-12 truncate">
{{ inboxName }}
</span>
</div>
</div>
</Dialog>
</template>