Merge branch 'develop' into feature/cw-7513

This commit is contained in:
Sony Mathew
2026-07-22 16:54:12 +05:30
committed by GitHub
107 changed files with 3032 additions and 554 deletions
@@ -0,0 +1,176 @@
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import { until } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useCallHistoryStore } from 'dashboard/stores/callHistory';
import CallListItem from 'dashboard/components-next/Calls/CallListItem.vue';
import CallsEmptyState from 'dashboard/components-next/Calls/CallsEmptyState.vue';
import CallsFilterBar from 'dashboard/components-next/Calls/CallsFilterBar.vue';
import { CALL_ACTIVITY_PARAMS } from 'dashboard/components-next/Calls/constants';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const RESULTS_PER_PAGE = 25;
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const callHistoryStore = useCallHistoryStore();
const inboxes = useMapGetter('inboxes/getInboxes');
const accountId = useMapGetter('getCurrentAccountId');
const currentUserId = useMapGetter('getCurrentUserID');
const agents = useMapGetter('agents/getVerifiedAgents');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
// CallFinder scopes non-admins to their own accepted calls, so the assignee
// filter is only meaningful for admins; everyone else defaults to themselves.
const { isAdmin } = useAdmin();
const voiceInboxes = computed(() => inboxes.value.filter(isVoiceCallEnabled));
const isVoiceEnabled = computed(
() =>
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CHANNEL_VOICE
) && voiceInboxes.value.length > 0
);
const calls = computed(() => callHistoryStore.records);
const meta = computed(() => callHistoryStore.meta);
const isFetching = computed(() => callHistoryStore.uiFlags.isFetching);
const accountUiFlags = useMapGetter('accounts/getUIFlags');
const isInitializing = ref(true);
// Filters are seeded from the URL so a shared link restores the same view.
const activity = ref(
CALL_ACTIVITY_PARAMS[route.query.activity] ? route.query.activity : null
);
const assigneeId = ref(
isAdmin.value ? Number(route.query.assignee_id) || null : currentUserId.value
);
const inboxId = ref(Number(route.query.inbox_id) || null);
const currentPage = ref(Number(route.query.page) || 1);
const syncFiltersToUrl = () => {
router.replace({
query: {
...(activity.value && { activity: activity.value }),
...(isAdmin.value &&
assigneeId.value && { assignee_id: assigneeId.value }),
...(inboxId.value && { inbox_id: inboxId.value }),
...(currentPage.value > 1 && { page: currentPage.value }),
},
});
};
const fetchCalls = async () => {
syncFiltersToUrl();
try {
await callHistoryStore.fetchCalls({
page: currentPage.value,
...(CALL_ACTIVITY_PARAMS[activity.value] || {}),
...(assigneeId.value ? { agent_id: assigneeId.value } : {}),
...(inboxId.value ? { inbox_id: inboxId.value } : {}),
});
} catch (error) {
useAlert(error.message);
}
};
watch([activity, assigneeId, inboxId], () => {
currentPage.value = 1;
fetchCalls();
});
const onPageChange = page => {
currentPage.value = page;
fetchCalls();
};
onMounted(async () => {
try {
await Promise.all([
store.dispatch('inboxes/get'),
until(() => accountUiFlags.value.isFetchingItem).toBe(false),
]);
if (!isVoiceEnabled.value) return;
// Only admins see the assignee filter, so only they need the agent list.
if (isAdmin.value) store.dispatch('agents/get');
await fetchCalls();
} finally {
isInitializing.value = false;
}
});
</script>
<template>
<div
v-if="isInitializing"
class="flex items-center justify-center w-full h-full bg-n-surface-1"
>
<Spinner :size="24" />
</div>
<CallsEmptyState v-else-if="!isVoiceEnabled" />
<section
v-else
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
>
<header class="px-6 pt-6 pb-4 shrink-0">
<div class="w-full">
<h1 class="text-xl font-medium text-n-slate-12">
{{ t('CALLS_PAGE.HEADER') }}
</h1>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full">
<div v-if="isFetching" class="flex items-center justify-center py-16">
<Spinner :size="24" />
</div>
<div
v-else-if="!calls.length"
class="flex items-center justify-center py-16"
>
<span class="text-base text-n-slate-11">
{{ t('CALLS_PAGE.EMPTY_STATE') }}
</span>
</div>
<template v-else>
<CallListItem v-for="call in calls" :key="call.id" :call="call" />
</template>
</div>
</main>
<footer v-if="calls.length" class="sticky bottom-0 shrink-0">
<PaginationFooter
:current-page="currentPage"
:total-items="meta.count"
:items-per-page="RESULTS_PER_PAGE"
@update:current-page="onPageChange"
/>
</footer>
</section>
</template>
@@ -0,0 +1,22 @@
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import {
CONVERSATION_PERMISSIONS,
ROLES,
} from 'dashboard/constants/permissions';
import { frontendURL } from '../../../helper/URLHelper';
import CallsIndex from './pages/CallsIndex.vue';
export const routes = [
{
path: frontendURL('accounts/:accountId/calls'),
name: 'calls_dashboard_index',
component: CallsIndex,
meta: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
];
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -27,19 +27,49 @@ const selectedRange = ref('this_month');
const assistantId = computed(() => route.params.assistantId);
const stats = ref(null);
const isFetching = ref(false);
// Increments on every fetch so a response (or retry) from a superseded
// range/assistant can't clobber the latest request's state.
let fetchToken = 0;
let abortController = null;
const fetchStats = async () => {
try {
const { data } = await CaptainAssistant.getStats({
fetchToken += 1;
const token = fetchToken;
abortController?.abort();
abortController = new AbortController();
const { signal } = abortController;
stats.value = null;
isFetching.value = true;
const requestStats = () =>
CaptainAssistant.getStats({
assistantId: assistantId.value,
range: selectedRange.value,
signal,
});
stats.value = data;
let data = null;
try {
({ data } = await requestStats());
} catch {
stats.value = null;
// One silent retry before giving up, unless the request was aborted.
try {
if (token === fetchToken && !signal.aborted)
({ data } = await requestStats());
} catch {
data = null;
}
}
if (token !== fetchToken || signal.aborted) return;
stats.value = data;
isFetching.value = false;
};
onUnmounted(() => abortController?.abort());
watch([selectedRange, assistantId], fetchStats, { immediate: true });
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
@@ -156,7 +186,7 @@ const closeDrilldown = () => {
<CoverageBanner :knowledge="stats?.knowledge" />
<WelcomeCard :range="selectedRange" />
<WelcomeCard :range="selectedRange" :stats="stats" />
<div
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
@@ -169,7 +199,8 @@ const closeDrilldown = () => {
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
:clickable="canDrilldown && Boolean(metric.metric)"
:loading="isFetching"
:clickable="canDrilldown && Boolean(metric.metric) && !isFetching"
@click="openDrilldown(metric)"
/>
</div>
@@ -1,6 +1,7 @@
import settings from './settings/settings.routes';
import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes';
import { routes as callRoutes } from './calls/routes';
import { routes as contactRoutes } from './contacts/routes';
import { routes as companyRoutes } from './companies/routes';
import { routes as notificationRoutes } from './notifications/routes';
@@ -25,6 +26,7 @@ export default {
...inboxRoutes,
...conversation.routes,
...settings.routes,
...callRoutes,
...contactRoutes,
...companyRoutes,
...searchRoutes,
@@ -18,9 +18,7 @@ export function useChannelConfig() {
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
(!isOnChatwootCloud.value ||
isCloudFeatureEnabled(
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
)) &&
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW)) &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
@@ -135,7 +135,7 @@ onMounted(() => {
<BaseTableCell class="max-w-0">
<div class="flex items-center gap-4 min-w-0">
<Avatar
:name="bot.name"
:name="bot.name || ''"
:src="bot.thumbnail"
:size="40"
class="flex-shrink-0"
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
@@ -14,7 +14,7 @@ const { accountId, currentAccount } = useAccount();
const globalConfig = useMapGetter('globalConfig/get');
const enabledFeatures = ref({});
const enabledFeatures = computed(() => currentAccount.value?.features || {});
const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
@@ -105,10 +105,6 @@ const channelList = computed(() => {
return channels;
});
const initializeEnabledFeatures = async () => {
enabledFeatures.value = currentAccount.value.features;
};
const initChannelAuth = channel => {
const params = {
sub_page: channel,
@@ -116,10 +112,6 @@ const initChannelAuth = channel => {
};
router.push({ name: 'settings_inboxes_page_channel', params });
};
onMounted(() => {
initializeEnabledFeatures();
});
</script>
<template>
@@ -390,6 +390,11 @@ export default {
return (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
(!this.isOnChatwootCloud ||
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
)) &&
this.inbox.reauthorization_required
);
},
@@ -42,9 +42,7 @@ const shouldShowWhatsappEmbeddedSignup = computed(() => {
selectedProvider.value === PROVIDER_TYPES.WHATSAPP &&
hasWhatsappAppId.value &&
(!isOnChatwootCloud.value ||
isCloudFeatureEnabled(
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
))
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW))
);
});
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import { useBranding } from 'shared/composables/useBranding';
const props = defineProps({
senderNameType: {
@@ -22,6 +23,7 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const senderNameKeyOptions = computed(() => [
{
@@ -30,7 +32,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'),
preview: {
senderName: 'Smith',
businessName: 'Chatwoot',
businessName: replaceInstallationName('Chatwoot'),
email: '<support@yourbusiness.com>',
},
},
@@ -40,7 +42,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'),
preview: {
senderName: '',
businessName: 'Chatwoot',
businessName: replaceInstallationName('Chatwoot'),
email: '<support@yourbusiness.com>',
},
},
@@ -51,7 +53,7 @@ const isKeyOptionFriendly = key => key === 'friendly';
const userName = keyOption =>
isKeyOptionFriendly(keyOption.key)
? keyOption.preview.senderName
: keyOption.preview.businessName;
: props.businessName || keyOption.preview.businessName;
const toggleSenderNameType = key => {
emit('update', key);
@@ -56,6 +56,7 @@ export default {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
@@ -65,7 +66,9 @@ export default {
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_RECONFIGURE
this.isOnChatwootCloud
? FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
: FEATURE_FLAGS.WHATSAPP_RECONFIGURE
)
);
},