Merge branch 'develop' into fix/hc-editor
This commit is contained in:
@@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
||||
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
import CustomToolsIndex from './tools/Index.vue';
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
@@ -124,4 +125,17 @@ export const routes = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/tools'),
|
||||
component: CustomToolsIndex,
|
||||
name: 'captain_tools_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
|
||||
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
|
||||
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
|
||||
const customTools = useMapGetter('captainCustomTools/getRecords');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const customToolsMeta = useMapGetter('captainCustomTools/getMeta');
|
||||
|
||||
const createDialogRef = ref(null);
|
||||
const deleteDialogRef = ref(null);
|
||||
const selectedTool = ref(null);
|
||||
const dialogType = ref('');
|
||||
|
||||
const fetchCustomTools = (page = 1) => {
|
||||
store.dispatch('captainCustomTools/get', { page });
|
||||
};
|
||||
|
||||
const onPageChange = page => fetchCustomTools(page);
|
||||
|
||||
const openCreateDialog = () => {
|
||||
dialogType.value = 'create';
|
||||
selectedTool.value = null;
|
||||
nextTick(() => createDialogRef.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleEdit = tool => {
|
||||
dialogType.value = 'edit';
|
||||
selectedTool.value = tool;
|
||||
nextTick(() => createDialogRef.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleDelete = tool => {
|
||||
selectedTool.value = tool;
|
||||
nextTick(() => deleteDialogRef.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
const tool = customTools.value.find(t => t.id === id);
|
||||
if (action === 'edit') {
|
||||
handleEdit(tool);
|
||||
} else if (action === 'delete') {
|
||||
handleDelete(tool);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
dialogType.value = '';
|
||||
selectedTool.value = null;
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
selectedTool.value = null;
|
||||
// Check if page will be empty after deletion
|
||||
if (customTools.value.length === 1 && customToolsMeta.value.page > 1) {
|
||||
// Go to previous page if current page will be empty
|
||||
onPageChange(customToolsMeta.value.page - 1);
|
||||
} else {
|
||||
// Refresh current page
|
||||
fetchCustomTools(customToolsMeta.value.page);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchCustomTools();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="$t('CAPTAIN.CUSTOM_TOOLS.HEADER')"
|
||||
:button-label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
|
||||
:button-policy="['administrator']"
|
||||
:total-count="customToolsMeta.totalCount"
|
||||
:current-page="customToolsMeta.page"
|
||||
:show-pagination-footer="!isFetching && !!customTools.length"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!customTools.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN_V2"
|
||||
@update:current-page="onPageChange"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<CustomToolsPageEmptyState @click="openCreateDialog" />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<CustomToolCard
|
||||
v-for="tool in customTools"
|
||||
:id="tool.id"
|
||||
:key="tool.id"
|
||||
:title="tool.title"
|
||||
:description="tool.description"
|
||||
:endpoint-url="tool.endpoint_url"
|
||||
:http-method="tool.http_method"
|
||||
:auth-type="tool.auth_type"
|
||||
:param-schema="tool.param_schema"
|
||||
:enabled="tool.enabled"
|
||||
:created-at="tool.created_at"
|
||||
:updated-at="tool.updated_at"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
|
||||
<CreateCustomToolDialog
|
||||
v-if="dialogType"
|
||||
ref="createDialogRef"
|
||||
:type="dialogType"
|
||||
:selected-tool="selectedTool"
|
||||
@close="handleDialogClose"
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
v-if="selectedTool"
|
||||
ref="deleteDialogRef"
|
||||
:entity="selectedTool"
|
||||
type="CustomTools"
|
||||
translation-key="CUSTOM_TOOLS"
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,21 +1,34 @@
|
||||
<script setup>
|
||||
import { watch, computed } from 'vue';
|
||||
import { watch, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
|
||||
import Spinner from 'next/spinner/Spinner.vue';
|
||||
|
||||
const { contactId } = defineProps({
|
||||
contactId: { type: String, required: true },
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contactId: { type: [String, Number], required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const uiFlags = useMapGetter('contactNotes/getUIFlags');
|
||||
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
|
||||
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
|
||||
const notGetterFn = useMapGetter('contactNotes/getAllNotesByContactId');
|
||||
const notes = computed(() => notGetterFn.value(contactId));
|
||||
const isCreatingNote = computed(() => uiFlags.value.isCreating);
|
||||
const contactId = computed(() => props.contactId);
|
||||
const noteContent = ref('');
|
||||
const shouldShowCreateModal = ref(false);
|
||||
const notes = computed(() => {
|
||||
if (!contactId.value) {
|
||||
return [];
|
||||
}
|
||||
return notesByContact.value(contactId.value) || [];
|
||||
});
|
||||
|
||||
const getWrittenBy = ({ user } = {}) => {
|
||||
const currentUserId = currentUser.value?.id;
|
||||
@@ -24,28 +37,130 @@ const getWrittenBy = ({ user } = {}) => {
|
||||
: user?.name || t('CONVERSATION.BOT');
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
if (!contactId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
noteContent.value = '';
|
||||
shouldShowCreateModal.value = true;
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
shouldShowCreateModal.value = false;
|
||||
noteContent.value = '';
|
||||
};
|
||||
|
||||
const onAdd = async () => {
|
||||
if (!contactId.value || !noteContent.value || isCreatingNote.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await store.dispatch('contactNotes/create', {
|
||||
content: noteContent.value,
|
||||
contactId: contactId.value,
|
||||
});
|
||||
noteContent.value = '';
|
||||
closeCreateModal();
|
||||
};
|
||||
|
||||
const onDelete = noteId => {
|
||||
if (!contactId.value || !noteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.dispatch('contactNotes/delete', {
|
||||
noteId,
|
||||
contactId: contactId.value,
|
||||
});
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
'$mod+Enter': {
|
||||
action: onAdd,
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
watch(
|
||||
() => contactId,
|
||||
() => store.dispatch('contactNotes/get', { contactId }),
|
||||
contactId,
|
||||
newContactId => {
|
||||
closeCreateModal();
|
||||
if (newContactId) {
|
||||
store.dispatch('contactNotes/get', { contactId: newContactId });
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isFetchingNotes" class="p-8 grid place-content-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="!notes.length" class="p-8 grid place-content-center">
|
||||
<p class="text-center">{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.NO_NOTES') }}</p>
|
||||
</div>
|
||||
<div v-else class="max-h-[300px] overflow-scroll">
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="p-4 last-of-type:border-b-0"
|
||||
:note="note"
|
||||
collapsible
|
||||
:written-by="getWrittenBy(note)"
|
||||
/>
|
||||
<div>
|
||||
<div class="px-4 pt-3 pb-2">
|
||||
<NextButton
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-plus"
|
||||
:label="$t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE')"
|
||||
:disabled="!contactId || isFetchingNotes"
|
||||
@click="openCreateModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFetchingNotes"
|
||||
class="flex items-center justify-center py-8 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="notes.length"
|
||||
class="flex flex-col max-h-[300px] overflow-y-auto"
|
||||
>
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="py-4 last-of-type:border-b-0 px-4"
|
||||
:note="note"
|
||||
:written-by="getWrittenBy(note)"
|
||||
allow-delete
|
||||
collapsible
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.CONVERSATION_EMPTY_STATE') }}
|
||||
</p>
|
||||
|
||||
<woot-modal
|
||||
v-model:show="shouldShowCreateModal"
|
||||
:on-close="closeCreateModal"
|
||||
:close-on-backdrop-click="false"
|
||||
class="!items-start [&>div]:!top-12 [&>div]:sticky"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-6 px-6 py-6">
|
||||
<h3 class="text-lg font-semibold text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE') }}
|
||||
</h3>
|
||||
<Editor
|
||||
v-model="noteContent"
|
||||
focus-on-mount
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4"
|
||||
/>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<NextButton
|
||||
solid
|
||||
blue
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
|
||||
:is-loading="isCreatingNote"
|
||||
:disabled="!noteContent || isCreatingNote"
|
||||
@click="onAdd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+12
-1
@@ -1,10 +1,14 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, required: true },
|
||||
withBorder: { type: Boolean, default: false },
|
||||
hideContent: { type: Boolean, default: false },
|
||||
beta: { type: Boolean, default: false },
|
||||
});
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -17,8 +21,15 @@ defineProps({
|
||||
>
|
||||
<header class="grid grid-cols-4">
|
||||
<div class="col-span-3">
|
||||
<h4 class="text-lg font-medium text-n-slate-12">
|
||||
<h4 class="text-lg font-medium text-n-slate-12 flex items-center gap-2">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
<div
|
||||
v-if="beta"
|
||||
v-tooltip.top="t('GENERAL.BETA_DESCRIPTION')"
|
||||
class="text-xs uppercase text-n-iris-11 border border-1 border-n-iris-10 leading-none rounded-lg px-1 py-0.5"
|
||||
>
|
||||
{{ t('GENERAL.BETA') }}
|
||||
</div>
|
||||
</h4>
|
||||
<p class="text-n-slate-11 text-sm mt-2">
|
||||
<slot name="description">{{ description }}</slot>
|
||||
|
||||
@@ -30,6 +30,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
provider: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
customRoleId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
@@ -203,6 +207,7 @@ const resetPassword = async () => {
|
||||
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
|
||||
<div class="w-[50%] ltr:text-left rtl:text-right">
|
||||
<Button
|
||||
v-if="provider !== 'saml'"
|
||||
ghost
|
||||
type="button"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
|
||||
@@ -261,6 +261,7 @@ const confirmDeletion = () => {
|
||||
v-if="showEditPopup"
|
||||
:id="currentAgent.id"
|
||||
:name="currentAgent.name"
|
||||
:provider="currentAgent.provider"
|
||||
:type="currentAgent.role"
|
||||
:email="currentAgent.email"
|
||||
:availability="currentAgent.availability_status"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { format } from 'date-fns';
|
||||
import sessionStorage from 'shared/helpers/sessionStorage';
|
||||
|
||||
import BillingMeter from './components/BillingMeter.vue';
|
||||
import BillingCard from './components/BillingCard.vue';
|
||||
@@ -13,7 +15,8 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
const { currentAccount } = useAccount();
|
||||
const router = useRouter();
|
||||
const { currentAccount, isOnChatwootCloud } = useAccount();
|
||||
const {
|
||||
captainEnabled,
|
||||
captainLimits,
|
||||
@@ -24,6 +27,12 @@ const {
|
||||
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
const store = useStore();
|
||||
|
||||
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
||||
|
||||
// State for handling refresh attempts and loading
|
||||
const isWaitingForBilling = ref(false);
|
||||
|
||||
const customAttributes = computed(() => {
|
||||
return currentAccount.value.custom_attributes || {};
|
||||
});
|
||||
@@ -61,11 +70,45 @@ const hasABillingPlan = computed(() => {
|
||||
|
||||
const fetchAccountDetails = async () => {
|
||||
if (!hasABillingPlan.value) {
|
||||
store.dispatch('accounts/subscription');
|
||||
await store.dispatch('accounts/subscription');
|
||||
fetchLimits();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingPageLogic = async () => {
|
||||
// If self-hosted, redirect to dashboard
|
||||
if (!isOnChatwootCloud.value) {
|
||||
router.push({ name: 'home' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've already attempted a refresh for billing setup
|
||||
const billingRefreshAttempted = sessionStorage.get(BILLING_REFRESH_ATTEMPTED);
|
||||
|
||||
// If cloud user, fetch account details first
|
||||
await fetchAccountDetails();
|
||||
|
||||
// If still no billing plan after fetch
|
||||
if (!hasABillingPlan.value) {
|
||||
// If we haven't attempted refresh yet, do it once
|
||||
if (!billingRefreshAttempted) {
|
||||
isWaitingForBilling.value = true;
|
||||
sessionStorage.set(BILLING_REFRESH_ATTEMPTED, true);
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 5000);
|
||||
} else {
|
||||
// We've already tried refreshing, so just show the no billing message
|
||||
// Clear the flag for future visits
|
||||
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
|
||||
}
|
||||
} else {
|
||||
// Billing plan found, clear any existing refresh flag
|
||||
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickBillingPortal = () => {
|
||||
store.dispatch('accounts/checkout');
|
||||
};
|
||||
@@ -76,14 +119,18 @@ const onToggleChatWindow = () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchAccountDetails);
|
||||
onMounted(handleBillingPageLogic);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.isFetchingItem"
|
||||
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
|
||||
:no-records-found="!hasABillingPlan"
|
||||
:is-loading="uiFlags.isFetchingItem || isWaitingForBilling"
|
||||
:loading-message="
|
||||
isWaitingForBilling
|
||||
? $t('BILLING_SETTINGS.NO_BILLING_USER')
|
||||
: $t('ATTRIBUTES_MGMT.LOADING')
|
||||
"
|
||||
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
|
||||
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
|
||||
>
|
||||
<template #header>
|
||||
|
||||
@@ -13,6 +13,7 @@ import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue'
|
||||
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
|
||||
import GoogleReauthorize from './channels/google/Reauthorize.vue';
|
||||
import WhatsappReauthorize from './channels/whatsapp/Reauthorize.vue';
|
||||
import InboxHealthAPI from 'dashboard/api/inboxHealth';
|
||||
import PreChatFormSettings from './PreChatForm/Settings.vue';
|
||||
import WeeklyAvailability from './components/WeeklyAvailability.vue';
|
||||
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
||||
@@ -21,6 +22,7 @@ import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vu
|
||||
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
||||
import WidgetBuilder from './WidgetBuilder.vue';
|
||||
import BotConfiguration from './components/BotConfiguration.vue';
|
||||
import AccountHealth from './components/AccountHealth.vue';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -51,6 +53,7 @@ export default {
|
||||
DuplicateInboxBanner,
|
||||
Editor,
|
||||
Avatar,
|
||||
AccountHealth,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
@@ -79,6 +82,9 @@ export default {
|
||||
selectedPortalSlug: '',
|
||||
showBusinessNameInput: false,
|
||||
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
|
||||
healthData: null,
|
||||
isLoadingHealth: false,
|
||||
healthError: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -175,6 +181,16 @@ export default {
|
||||
},
|
||||
];
|
||||
}
|
||||
if (this.shouldShowWhatsAppConfiguration) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
{
|
||||
key: 'whatsappHealth',
|
||||
name: this.$t('INBOX_MGMT.TABS.ACCOUNT_HEALTH'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return visibleToAllChannelTabs;
|
||||
},
|
||||
currentInboxId() {
|
||||
@@ -206,7 +222,8 @@ export default {
|
||||
this.isASmsInbox ||
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAFacebookInbox ||
|
||||
this.isAPIInbox
|
||||
this.isAPIInbox ||
|
||||
this.isATelegramChannel
|
||||
);
|
||||
},
|
||||
inboxNameLabel() {
|
||||
@@ -259,14 +276,30 @@ export default {
|
||||
this.inbox.reauthorization_required
|
||||
);
|
||||
},
|
||||
isEmbeddedSignupWhatsApp() {
|
||||
return this.inbox.provider_config?.source === 'embedded_signup';
|
||||
},
|
||||
whatsappUnauthorized() {
|
||||
return (
|
||||
this.isAWhatsAppChannel &&
|
||||
this.inbox.provider === 'whatsapp_cloud' &&
|
||||
this.inbox.provider_config?.source === 'embedded_signup' &&
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
this.inbox.reauthorization_required
|
||||
);
|
||||
},
|
||||
whatsappRegistrationIncomplete() {
|
||||
if (
|
||||
!this.healthData ||
|
||||
!this.isAWhatsAppCloudChannel ||
|
||||
!this.isEmbeddedSignupWhatsApp
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.healthData.platform_type === 'NOT_APPLICABLE' ||
|
||||
this.healthData.throughput?.level === 'NOT_APPLICABLE'
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
$route(to) {
|
||||
@@ -274,15 +307,40 @@ export default {
|
||||
this.fetchInboxSettings();
|
||||
}
|
||||
},
|
||||
inbox: {
|
||||
handler() {
|
||||
this.fetchHealthData();
|
||||
},
|
||||
immediate: false,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchInboxSettings();
|
||||
this.fetchPortals();
|
||||
this.fetchHealthData();
|
||||
},
|
||||
methods: {
|
||||
fetchPortals() {
|
||||
this.$store.dispatch('portals/index');
|
||||
},
|
||||
async fetchHealthData() {
|
||||
if (!this.inbox) return;
|
||||
|
||||
if (!this.isAWhatsAppCloudChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isLoadingHealth = true;
|
||||
this.healthError = null;
|
||||
const response = await InboxHealthAPI.getHealthStatus(this.inbox.id);
|
||||
this.healthData = response.data;
|
||||
} catch (error) {
|
||||
this.healthError = error.message || 'Failed to fetch health data';
|
||||
} finally {
|
||||
this.isLoadingHealth = false;
|
||||
}
|
||||
},
|
||||
handleFeatureFlag(e) {
|
||||
this.selectedFeatureFlags = this.toggleInput(
|
||||
this.selectedFeatureFlags,
|
||||
@@ -341,7 +399,7 @@ export default {
|
||||
try {
|
||||
const payload = {
|
||||
id: this.currentInboxId,
|
||||
name: this.selectedInboxName,
|
||||
name: this.selectedInboxName?.trim(),
|
||||
enable_email_collect: this.emailCollectEnabled,
|
||||
allow_messages_after_resolved: this.allowMessagesAfterResolved,
|
||||
greeting_enabled: this.greetingEnabled,
|
||||
@@ -445,7 +503,11 @@ export default {
|
||||
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
|
||||
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
|
||||
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
|
||||
<WhatsappReauthorize v-if="whatsappUnauthorized" :inbox="inbox" />
|
||||
<WhatsappReauthorize
|
||||
v-if="whatsappUnauthorized"
|
||||
:whatsapp-registration-incomplete="whatsappRegistrationIncomplete"
|
||||
:inbox="inbox"
|
||||
/>
|
||||
<DuplicateInboxBanner
|
||||
v-if="hasDuplicateInstagramInbox"
|
||||
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
|
||||
@@ -457,7 +519,7 @@ export default {
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_SUB_TEXT')"
|
||||
:show-border="false"
|
||||
>
|
||||
<div class="flex flex-col mb-4 items-start gap-1">
|
||||
<div class="flex flex-col gap-1 items-start mb-4">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL') }}
|
||||
</label>
|
||||
@@ -855,6 +917,9 @@ export default {
|
||||
<div v-if="selectedTabKey === 'botConfiguration'">
|
||||
<BotConfiguration :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'whatsappHealth'">
|
||||
<AccountHealth :health-data="healthData" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ export default {
|
||||
const whatsappChannel = await this.$store.dispatch(
|
||||
'inboxes/createChannel',
|
||||
{
|
||||
name: this.inboxName,
|
||||
name: this.inboxName?.trim(),
|
||||
channel: {
|
||||
type: 'whatsapp',
|
||||
phone_number: this.phoneNumber,
|
||||
|
||||
@@ -42,7 +42,7 @@ export default {
|
||||
|
||||
try {
|
||||
const apiChannel = await this.$store.dispatch('inboxes/createChannel', {
|
||||
name: this.channelName,
|
||||
name: this.channelName?.trim(),
|
||||
channel: {
|
||||
type: 'api',
|
||||
webhook_url: this.webhookUrl,
|
||||
|
||||
@@ -48,7 +48,7 @@ export default {
|
||||
|
||||
try {
|
||||
const smsChannel = await this.$store.dispatch('inboxes/createChannel', {
|
||||
name: this.inboxName,
|
||||
name: this.inboxName?.trim(),
|
||||
channel: {
|
||||
type: 'sms',
|
||||
phone_number: this.phoneNumber,
|
||||
|
||||
@@ -45,7 +45,7 @@ export default {
|
||||
const whatsappChannel = await this.$store.dispatch(
|
||||
'inboxes/createChannel',
|
||||
{
|
||||
name: this.inboxName,
|
||||
name: this.inboxName?.trim(),
|
||||
channel: {
|
||||
type: 'whatsapp',
|
||||
phone_number: this.phoneNumber,
|
||||
|
||||
@@ -179,7 +179,7 @@ export default {
|
||||
user_access_token: this.user_access_token,
|
||||
page_access_token: this.selectedPage.access_token,
|
||||
page_id: this.selectedPage.id,
|
||||
inbox_name: this.selectedPage.name,
|
||||
inbox_name: this.selectedPage.name?.trim(),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export default {
|
||||
const lineChannel = await this.$store.dispatch(
|
||||
'inboxes/createChannel',
|
||||
{
|
||||
name: this.channelName,
|
||||
name: this.channelName?.trim(),
|
||||
channel: {
|
||||
type: 'line',
|
||||
line_channel_id: this.lineChannelId,
|
||||
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
'inboxes/createTwilioChannel',
|
||||
{
|
||||
twilio_channel: {
|
||||
name: this.channelName,
|
||||
name: this.channelName?.trim(),
|
||||
medium: this.medium,
|
||||
account_sid: this.accountSID,
|
||||
api_key_sid: this.apiKeySID,
|
||||
|
||||
@@ -47,7 +47,7 @@ export default {
|
||||
const website = await this.$store.dispatch(
|
||||
'inboxes/createWebsiteChannel',
|
||||
{
|
||||
name: this.inboxName,
|
||||
name: this.inboxName?.trim(),
|
||||
greeting_enabled: this.greetingEnabled,
|
||||
greeting_message: this.greetingMessage,
|
||||
channel: {
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ export default {
|
||||
const emailChannel = await this.$store.dispatch(
|
||||
'inboxes/createChannel',
|
||||
{
|
||||
name: this.channelName,
|
||||
name: this.channelName?.trim(),
|
||||
channel: {
|
||||
type: 'email',
|
||||
email: this.email,
|
||||
|
||||
+20
@@ -16,6 +16,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
whatsappRegistrationIncomplete: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -28,6 +32,20 @@ const whatsappConfigurationId = computed(
|
||||
() => window.chatwootConfig.whatsappConfigurationId
|
||||
);
|
||||
|
||||
const actionLabel = computed(() => {
|
||||
if (props.whatsappRegistrationIncomplete) {
|
||||
return t('INBOX_MGMT.COMPLETE_REGISTRATION');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const description = computed(() => {
|
||||
if (props.whatsappRegistrationIncomplete) {
|
||||
return t('INBOX_MGMT.WHATSAPP_REGISTRATION_INCOMPLETE');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const reauthorizeWhatsApp = async params => {
|
||||
isRequestingAuthorization.value = true;
|
||||
|
||||
@@ -185,6 +203,8 @@ defineExpose({
|
||||
<InboxReconnectionRequired
|
||||
class="mx-8 mt-5"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:action-label="actionLabel"
|
||||
:description="description"
|
||||
@reauthorize="requestAuthorization"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
healthData: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const QUALITY_COLORS = {
|
||||
GREEN: 'text-n-teal-11',
|
||||
YELLOW: 'text-n-amber-11',
|
||||
RED: 'text-n-ruby-11',
|
||||
UNKNOWN: 'text-n-slate-12',
|
||||
};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
APPROVED: 'text-n-teal-11',
|
||||
PENDING_REVIEW: 'text-n-amber-11',
|
||||
AVAILABLE_WITHOUT_REVIEW: 'text-n-teal-11',
|
||||
REJECTED: 'text-n-ruby-9',
|
||||
DECLINED: 'text-n-ruby-9',
|
||||
};
|
||||
|
||||
const MODE_COLORS = {
|
||||
LIVE: 'text-n-teal-11',
|
||||
SANDBOX: 'text-n-slate-11',
|
||||
};
|
||||
|
||||
const healthItems = computed(() => {
|
||||
if (!props.healthData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const {
|
||||
display_phone_number: displayPhoneNumber,
|
||||
verified_name: verifiedName,
|
||||
name_status: nameStatus,
|
||||
quality_rating: qualityRating,
|
||||
messaging_limit_tier: messagingLimitTier,
|
||||
account_mode: accountMode,
|
||||
} = props.healthData;
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'displayPhoneNumber',
|
||||
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_PHONE_NUMBER.LABEL'),
|
||||
value: displayPhoneNumber || 'N/A',
|
||||
tooltip: t(
|
||||
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_PHONE_NUMBER.TOOLTIP'
|
||||
),
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
key: 'verifiedName',
|
||||
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.VERIFIED_NAME.LABEL'),
|
||||
value: verifiedName || 'N/A',
|
||||
tooltip: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.VERIFIED_NAME.TOOLTIP'),
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
key: 'displayNameStatus',
|
||||
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_NAME_STATUS.LABEL'),
|
||||
value: nameStatus || 'UNKNOWN',
|
||||
tooltip: t(
|
||||
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_NAME_STATUS.TOOLTIP'
|
||||
),
|
||||
show: true,
|
||||
type: 'status',
|
||||
},
|
||||
{
|
||||
key: 'qualityRating',
|
||||
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.QUALITY_RATING.LABEL'),
|
||||
value: qualityRating || 'UNKNOWN',
|
||||
tooltip: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.QUALITY_RATING.TOOLTIP'),
|
||||
show: true,
|
||||
type: 'quality',
|
||||
},
|
||||
{
|
||||
key: 'messagingLimitTier',
|
||||
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.MESSAGING_LIMIT_TIER.LABEL'),
|
||||
value: messagingLimitTier || 'UNKNOWN',
|
||||
tooltip: t(
|
||||
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.MESSAGING_LIMIT_TIER.TOOLTIP'
|
||||
),
|
||||
show: true,
|
||||
type: 'tier',
|
||||
},
|
||||
{
|
||||
key: 'accountMode',
|
||||
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.ACCOUNT_MODE.LABEL'),
|
||||
value: accountMode || 'UNKNOWN',
|
||||
tooltip: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.ACCOUNT_MODE.TOOLTIP'),
|
||||
show: true,
|
||||
type: 'mode',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
const { business_id: businessId } = props.healthData || {};
|
||||
|
||||
if (businessId) {
|
||||
// WhatsApp Business Manager URL with specific business ID and phone numbers tab
|
||||
const whatsappBusinessUrl = `https://business.facebook.com/latest/whatsapp_manager/phone_numbers/?business_id=${businessId}&tab=phone-numbers`;
|
||||
window.open(whatsappBusinessUrl, '_blank');
|
||||
} else {
|
||||
// Fallback to general WhatsApp Business Manager if business_id is not available
|
||||
const fallbackUrl = 'https://business.facebook.com/';
|
||||
window.open(fallbackUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const getQualityRatingTextColor = rating =>
|
||||
QUALITY_COLORS[rating] || QUALITY_COLORS.UNKNOWN;
|
||||
|
||||
const formatTierDisplay = tier =>
|
||||
t(`INBOX_MGMT.ACCOUNT_HEALTH.VALUES.TIERS.${tier}`) || tier;
|
||||
|
||||
const formatStatusDisplay = status =>
|
||||
t(`INBOX_MGMT.ACCOUNT_HEALTH.VALUES.STATUSES.${status}`) || status;
|
||||
|
||||
const formatModeDisplay = mode =>
|
||||
t(`INBOX_MGMT.ACCOUNT_HEALTH.VALUES.MODES.${mode}`) || mode;
|
||||
|
||||
const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12';
|
||||
|
||||
const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gap-4 pt-8 mx-8">
|
||||
<div
|
||||
class="px-5 py-5 space-y-6 rounded-xl border shadow-sm border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-5 justify-between items-start w-full md:flex-row"
|
||||
>
|
||||
<div>
|
||||
<span class="text-base font-medium text-n-slate-12">
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.TITLE') }}
|
||||
</span>
|
||||
<p class="mt-1 text-sm text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonV4
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
class="flex-shrink-0"
|
||||
@click="handleGoToSettings"
|
||||
>
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.GO_TO_SETTINGS') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
|
||||
<div v-if="healthData" class="grid grid-cols-1 gap-4 xs:grid-cols-2">
|
||||
<div
|
||||
v-for="item in healthItems"
|
||||
:key="item.key"
|
||||
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm font-medium text-n-slate-11">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<Icon
|
||||
v-tooltip.top="item.tooltip"
|
||||
icon="i-lucide-info"
|
||||
class="flex-shrink-0 w-4 h-4 cursor-help text-n-slate-9"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
v-if="item.type === 'quality'"
|
||||
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
|
||||
:class="getQualityRatingTextColor(item.value)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.type === 'status'"
|
||||
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
|
||||
:class="getStatusTextColor(item.value)"
|
||||
>
|
||||
{{ formatStatusDisplay(item.value) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.type === 'mode'"
|
||||
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
|
||||
:class="getModeStatusTextColor(item.value)"
|
||||
>
|
||||
{{ formatModeDisplay(item.value) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.type === 'tier'"
|
||||
class="text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{ formatTierDisplay(item.value) }}
|
||||
</span>
|
||||
<span v-else class="text-sm font-medium text-n-slate-12">{{
|
||||
item.value
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="pt-8">
|
||||
<div
|
||||
class="flex justify-center items-center p-8 text-center text-n-slate-11"
|
||||
>
|
||||
<div>
|
||||
<Icon icon="i-lucide-activity" class="mb-2 w-8 h-8" />
|
||||
<p class="text-sm">{{ t('INBOX_MGMT.ACCOUNT_HEALTH.NO_DATA') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -96,11 +96,12 @@ export default {
|
||||
return parse(this.toTime, 'hh:mm a', new Date());
|
||||
},
|
||||
totalHours() {
|
||||
if (this.timeSlot.openAllDay) {
|
||||
return 24;
|
||||
}
|
||||
const totalHours = differenceInMinutes(this.toDate, this.fromDate) / 60;
|
||||
return totalHours;
|
||||
if (this.timeSlot.openAllDay) return '24h';
|
||||
|
||||
const totalMinutes = differenceInMinutes(this.toDate, this.fromDate);
|
||||
const [h, m] = [Math.floor(totalMinutes / 60), totalMinutes % 60];
|
||||
|
||||
return [h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ') || '0m';
|
||||
},
|
||||
hasError() {
|
||||
return !this.timeSlot.valid;
|
||||
@@ -211,7 +212,7 @@ export default {
|
||||
v-if="isDayEnabled && !hasError"
|
||||
class="label bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text text-xs inline-block px-2 py-1 rounded-lg cursor-default whitespace-nowrap"
|
||||
>
|
||||
{{ totalHours }} {{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.HOURS') }}
|
||||
{{ totalHours }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+13
-2
@@ -1,15 +1,26 @@
|
||||
<script setup>
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
|
||||
defineProps({
|
||||
actionLabel: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['reauthorize']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
color="ruby"
|
||||
:action-label="$t('INBOX_MGMT.CLICK_TO_RECONNECT')"
|
||||
:action-label="actionLabel || $t('INBOX_MGMT.CLICK_TO_RECONNECT')"
|
||||
@action="emit('reauthorize')"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.RECONNECTION_REQUIRED') }}
|
||||
{{ description || $t('INBOX_MGMT.RECONNECTION_REQUIRED') }}
|
||||
</Banner>
|
||||
</template>
|
||||
|
||||
@@ -53,6 +53,7 @@ export const generateTimeSlots = (step = 15) => {
|
||||
Generates a list of time strings from 12:00 AM to next 24 hours. Each new string
|
||||
will be generated by adding `step` minutes to the previous one.
|
||||
The list is generated by starting with a random day and adding step minutes till end of the same day.
|
||||
Always includes 11:59 PM as the final slot to complete the day.
|
||||
*/
|
||||
const date = new Date(1970, 1, 1);
|
||||
const slots = [];
|
||||
@@ -66,6 +67,13 @@ export const generateTimeSlots = (step = 15) => {
|
||||
);
|
||||
date.setMinutes(date.getMinutes() + step);
|
||||
}
|
||||
|
||||
// Always add 11:59 PM as the final slot if it's not already included
|
||||
const lastSlot = '11:59 PM';
|
||||
if (!slots.includes(lastSlot)) {
|
||||
slots.push(lastSlot);
|
||||
}
|
||||
|
||||
return slots;
|
||||
};
|
||||
|
||||
|
||||
+55
-3
@@ -7,10 +7,19 @@ import {
|
||||
} from '../businessHour';
|
||||
|
||||
describe('#generateTimeSlots', () => {
|
||||
it('returns correct number of time slots', () => {
|
||||
expect(generateTimeSlots(15).length).toStrictEqual((60 / 15) * 24);
|
||||
it('returns correct number of time slots for 15-minute intervals', () => {
|
||||
const slots = generateTimeSlots(15);
|
||||
// 24 hours * 4 slots per hour + 1 for 11:59 PM = 97 slots
|
||||
expect(slots.length).toStrictEqual(97);
|
||||
});
|
||||
it('returns correct time slots', () => {
|
||||
|
||||
it('returns correct number of time slots for 30-minute intervals', () => {
|
||||
const slots = generateTimeSlots(30);
|
||||
// 24 hours * 2 slots per hour + 1 for 11:59 PM = 49 slots
|
||||
expect(slots.length).toStrictEqual(49);
|
||||
});
|
||||
|
||||
it('returns correct time slots for 4-hour intervals', () => {
|
||||
expect(generateTimeSlots(240)).toStrictEqual([
|
||||
'12:00 AM',
|
||||
'04:00 AM',
|
||||
@@ -18,8 +27,51 @@ describe('#generateTimeSlots', () => {
|
||||
'12:00 PM',
|
||||
'04:00 PM',
|
||||
'08:00 PM',
|
||||
'11:59 PM',
|
||||
]);
|
||||
});
|
||||
|
||||
it('always starts with 12:00 AM', () => {
|
||||
expect(generateTimeSlots(15)[0]).toStrictEqual('12:00 AM');
|
||||
expect(generateTimeSlots(30)[0]).toStrictEqual('12:00 AM');
|
||||
expect(generateTimeSlots(60)[0]).toStrictEqual('12:00 AM');
|
||||
});
|
||||
|
||||
it('always ends with 11:59 PM', () => {
|
||||
const slots15 = generateTimeSlots(15);
|
||||
const slots30 = generateTimeSlots(30);
|
||||
const slots60 = generateTimeSlots(60);
|
||||
|
||||
expect(slots15[slots15.length - 1]).toStrictEqual('11:59 PM');
|
||||
expect(slots30[slots30.length - 1]).toStrictEqual('11:59 PM');
|
||||
expect(slots60[slots60.length - 1]).toStrictEqual('11:59 PM');
|
||||
});
|
||||
|
||||
it('includes 11:59 PM even when it would not be in regular intervals', () => {
|
||||
const slots = generateTimeSlots(30);
|
||||
expect(slots).toContain('11:59 PM');
|
||||
expect(slots).toContain('11:30 PM'); // Regular interval
|
||||
});
|
||||
|
||||
it('does not duplicate 11:59 PM if it already exists in regular intervals', () => {
|
||||
// Test with a step that would naturally include 11:59 PM
|
||||
const slots = generateTimeSlots(1); // 1-minute intervals
|
||||
const count11_59 = slots.filter(slot => slot === '11:59 PM').length;
|
||||
expect(count11_59).toStrictEqual(1);
|
||||
});
|
||||
|
||||
it('generates correct time format', () => {
|
||||
const slots = generateTimeSlots(60);
|
||||
expect(slots).toContain('01:00 AM');
|
||||
expect(slots).toContain('12:00 PM');
|
||||
expect(slots).toContain('01:00 PM');
|
||||
expect(slots).toContain('11:00 PM');
|
||||
});
|
||||
|
||||
it('handles edge case with very large step', () => {
|
||||
const slots = generateTimeSlots(1440); // 24 hours
|
||||
expect(slots).toStrictEqual(['12:00 AM', '11:59 PM']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getTime', () => {
|
||||
|
||||
+52
@@ -7,7 +7,9 @@ import SmtpSettings from '../SmtpSettings.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TextArea from 'next/textarea/TextArea.vue';
|
||||
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
|
||||
import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -15,6 +17,7 @@ export default {
|
||||
ImapSettings,
|
||||
SmtpSettings,
|
||||
NextButton,
|
||||
TextArea,
|
||||
WhatsappReauthorize,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
@@ -33,6 +36,8 @@ export default {
|
||||
whatsAppInboxAPIKey: '',
|
||||
isRequestingReauthorization: false,
|
||||
isSyncingTemplates: false,
|
||||
allowedDomains: '',
|
||||
isUpdatingAllowedDomains: false,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@@ -57,6 +62,7 @@ export default {
|
||||
methods: {
|
||||
setDefaults() {
|
||||
this.hmacMandatory = this.inbox.hmac_mandatory || false;
|
||||
this.allowedDomains = this.inbox.allowed_domains || '';
|
||||
},
|
||||
handleHmacFlag() {
|
||||
this.updateInbox();
|
||||
@@ -76,6 +82,28 @@ export default {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
async updateAllowedDomains() {
|
||||
this.isUpdatingAllowedDomains = true;
|
||||
const sanitizedAllowedDomains = sanitizeAllowedDomains(
|
||||
this.allowedDomains
|
||||
);
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
allowed_domains: sanitizedAllowedDomains,
|
||||
},
|
||||
};
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
this.allowedDomains = sanitizedAllowedDomains;
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
this.isUpdatingAllowedDomains = false;
|
||||
}
|
||||
},
|
||||
async updateWhatsAppInboxAPIKey() {
|
||||
try {
|
||||
const payload = {
|
||||
@@ -180,6 +208,30 @@ export default {
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.SUBTITLE')"
|
||||
>
|
||||
<div class="flex flex-col w-full max-w-3xl gap-4">
|
||||
<TextArea
|
||||
v-model="allowedDomains"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.PLACEHOLDER')
|
||||
"
|
||||
auto-height
|
||||
min-height="8rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<div>
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:is-loading="isUpdatingAllowedDomains"
|
||||
@click="updateAllowedDomains"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
|
||||
>
|
||||
|
||||
+8
-3
@@ -27,6 +27,11 @@ const conversationLabels = computed(() => {
|
||||
? props.conversation.labels.split(',').map(item => item.trim())
|
||||
: [];
|
||||
});
|
||||
|
||||
const routerParams = computed(() => ({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: props.conversationId },
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,9 +41,9 @@ const conversationLabels = computed(() => {
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-n-slate-12 rtl:text-right"
|
||||
>
|
||||
<span class="text-n-slate-12">
|
||||
{{ `#${conversationId} ` }}
|
||||
</span>
|
||||
<router-link :to="routerParams" class="text-n-slate-12 hover:underline">
|
||||
{{ `#${conversationId}` }}
|
||||
</router-link>
|
||||
<span class="text-n-slate-11">
|
||||
{{ $t('SLA_REPORTS.WITH') }}
|
||||
</span>
|
||||
|
||||
+1
@@ -171,6 +171,7 @@ onMounted(() => {
|
||||
<SectionLayout
|
||||
:title="t('SECURITY_SETTINGS.SAML.TITLE')"
|
||||
:description="t('SECURITY_SETTINGS.SAML.NOTE')"
|
||||
beta
|
||||
:hide-content="!hasFeature || !isEnabled || isLoading"
|
||||
>
|
||||
<template #headerActions>
|
||||
|
||||
Reference in New Issue
Block a user