feat: introduce voice call dashboard (#14954)

This commit is contained in:
Sivin Varghese
2026-07-20 14:54:05 +05:30
committed by GitHub
parent a752e56765
commit bf0a10c780
20 changed files with 1426 additions and 5 deletions
@@ -0,0 +1,168 @@
<script setup>
import { computed, ref, watch } from 'vue';
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 inboxesUiFlags = useMapGetter('inboxes/getUIFlags');
// 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();
};
// inboxes/get flips isFetching true synchronously, so the spinner shows on the
// first render and the setup CTA never flashes; hit the calls endpoint only
// once inboxes confirm voice is on.
store.dispatch('inboxes/get').then(() => {
if (!isVoiceEnabled.value) return;
// Only admins see the assignee filter, so only they need the agent list.
if (isAdmin.value) store.dispatch('agents/get');
fetchCalls();
});
</script>
<template>
<div
v-if="inboxesUiFlags.isFetching"
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,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,