diff --git a/Gemfile b/Gemfile index e10984f53..680a0738b 100644 --- a/Gemfile +++ b/Gemfile @@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0' # two-factor authentication gem 'devise-two-factor', '>= 5.0.0' # authorization -gem 'jwt' +gem 'jwt', '~> 2.10', '>= 2.10.3' gem 'pundit' # super admin diff --git a/Gemfile.lock b/Gemfile.lock index 4da0e5847..0479393b0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -301,7 +301,7 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.2) faraday-net_http (>= 2.0, < 3.5) json logger @@ -491,7 +491,7 @@ GEM judoscale-sidekiq (1.8.2) judoscale-ruby (= 1.8.2) sidekiq (>= 5.0) - jwt (2.10.1) + jwt (2.10.3) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -1102,7 +1102,7 @@ DEPENDENCIES json_schemer judoscale-rails judoscale-sidekiq - jwt + jwt (~> 2.10, >= 2.10.3) kaminari koala letter_opener diff --git a/app/javascript/dashboard/components-next/call/CallCard.vue b/app/javascript/dashboard/components-next/call/CallCard.vue index fcd7ad232..207129282 100644 --- a/app/javascript/dashboard/components-next/call/CallCard.vue +++ b/app/javascript/dashboard/components-next/call/CallCard.vue @@ -35,7 +35,14 @@ const props = defineProps({ }, }); -defineEmits(['accept', 'reject', 'end', 'toggleMute', 'goToConversation']); +defineEmits([ + 'accept', + 'reject', + 'end', + 'toggleMute', + 'goToConversation', + 'dismiss', +]); const { t } = useI18n(); @@ -115,6 +122,19 @@ const channelIcon = computed(() => { {{ statusLabel }} + + diff --git a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue index 39e75ddbf..86d9e979c 100644 --- a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue @@ -25,6 +25,7 @@ const { joinCall, endCall: endCallSession, rejectIncomingCall, + dismissCall, formattedCallDuration, } = useCallSession(); @@ -51,6 +52,15 @@ const mainCardState = computed(() => { : VOICE_CALL_DIRECTION.INCOMING; }); +// Stacked cards are always non-active (ringing) calls, so reflect each call's +// real direction. An outbound call must render as OUTGOING — otherwise it shows +// the incoming-only dismiss (✕) control and the agent could drop it locally +// without terminating, leaving the customer ringing with no widget to end it. +const stackedCardState = call => + call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND + ? VOICE_CALL_DIRECTION.OUTGOING + : VOICE_CALL_DIRECTION.INCOMING; + const toggleMute = () => { isMuted.value = !isMuted.value; setWhatsappCallMuted(isMuted.value); @@ -230,10 +240,11 @@ onBeforeUnmount(stopRingtone); v-for="call in stackedIncomingCalls" :key="call.callSid" :call="call" - :state="VOICE_CALL_DIRECTION.INCOMING" + :state="stackedCardState(call)" :call-info="getCallInfo(call)" @accept="handleJoinCall(call)" @reject="rejectIncomingCall(call.callSid)" + @dismiss="dismissCall(call.callSid)" @go-to-conversation="goToConversation(call)" /> @@ -248,6 +259,7 @@ onBeforeUnmount(stopRingtone); :show-mute="hasActiveCall && isWhatsappActive" @accept="handleJoinCall(primaryIncomingCall)" @reject="rejectIncomingCall(primaryIncomingCall?.callSid)" + @dismiss="dismissCall(primaryIncomingCall?.callSid)" @end="handleEndCall" @toggle-mute="toggleMute" @go-to-conversation="goToConversation(activeCall || primaryIncomingCall)" diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentBulkActions.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentBulkActions.vue index 378860b3e..fa4888576 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/DocumentBulkActions.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentBulkActions.vue @@ -25,7 +25,7 @@ const store = useStore(); const bulkDeleteDialog = ref(null); const isSyncableDocument = doc => - !doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress; + !doc.pdf_document && doc.status === 'available'; const syncableSelectedIds = computed(() => { if (!props.selectedIds.size) return []; diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue index c8a011b9a..9d6d574ec 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue @@ -127,7 +127,6 @@ const menuItems = computed(() => { value: 'sync', action: 'sync', icon: 'i-lucide-refresh-cw', - disabled: props.syncInProgress, }); } diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index ae9c4ec75..37a80e8fc 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -91,10 +91,6 @@ const wasDeclinedByAgent = computed( endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED ); const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId); -const didCurrentUserAnswer = computed( - () => - !!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value -); const conversationAssignee = computed(() => { const conversation = store.getters.getConversationById?.( conversationId?.value @@ -124,43 +120,48 @@ const durationSeconds = computed(() => { const formattedDuration = computed(() => formatDuration(durationSeconds.value)); +// Agent who handled the call (initiator on outbound, answerer on inbound), taken +// strictly from the persisted accept fields — never the conversation's current +// assignee, which would mis-attribute a historical call after a reassignment. +const handlerName = computed(() => { + if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName; + if (!acceptedByAgentId.value) return null; + const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value); + return agent?.available_name || agent?.name || null; +}); + +const handledBy = computed(() => + handlerName.value + ? t('CONVERSATION.VOICE_CALL.HANDLED_BY', { agentName: handlerName.value }) + : null +); + const labelKey = computed(() => { if (LABEL_MAP[status.value]) return LABEL_MAP[status.value]; - if (status.value === VOICE_CALL_STATUS.RINGING) { - return isOutbound.value - ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL' - : 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; - } if (isFailed.value) { return isOutbound.value ? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL' : 'CONVERSATION.VOICE_CALL.MISSED_CALL'; } - return 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; + // RINGING or an as-yet-unknown/initial status: orient purely by direction so an + // outbound call never falls through to the "Incoming call" label. + return isOutbound.value + ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL' + : 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; }); const subtext = computed(() => { - if (status.value === VOICE_CALL_STATUS.RINGING) { - return isOutbound.value - ? t('CONVERSATION.VOICE_CALL.CALLING') - : t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); - } + // Completed: "Handled by {agent} · 0:42" (drops either part when absent). if (status.value === VOICE_CALL_STATUS.COMPLETED) { - return formattedDuration.value; + return [handledBy.value, formattedDuration.value] + .filter(Boolean) + .join(' · '); } if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { - if (isOutbound.value) return null; - if (didCurrentUserAnswer.value) { - return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED'); - } - if (displayAgentName.value) { - return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', { - agentName: displayAgentName.value, - }); - } - return null; + return handledBy.value; } if (isFailed.value) { + // Missed/failed calls have no handler, so keep the reason rather than "Handled by". if (isOutbound.value) { return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT'); } @@ -171,6 +172,10 @@ const subtext = computed(() => { } return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT'); } + // RINGING or an as-yet-unknown/initial status. + if (isOutbound.value) { + return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING'); + } return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); }); diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index ec50d4a62..8b3dbb850 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -31,6 +31,17 @@ const timeStampURL = computed(() => { return timeStampAppendedURL(attachment.dataUrl); }); +const TRANSCRIPT_PREVIEW_LENGTH = 200; +const isTranscriptExpanded = ref(false); +const isTranscriptLong = computed( + () => (attachment.transcribedText?.length || 0) > TRANSCRIPT_PREVIEW_LENGTH +); +const displayedTranscript = computed(() => { + const text = attachment.transcribedText || ''; + if (!isTranscriptLong.value || isTranscriptExpanded.value) return text; + return `${text.slice(0, TRANSCRIPT_PREVIEW_LENGTH).trimEnd()}…`; +}); + const audioPlayer = useTemplateRef('audioPlayer'); const isPlaying = ref(false); @@ -215,7 +226,18 @@ const downloadAudio = async () => { v-if="attachment.transcribedText && showTranscribedText" class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words" > - {{ attachment.transcribedText }} + {{ displayedTranscript }} + diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue index 66e416f96..a1eb1b828 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue @@ -78,26 +78,27 @@ function changeAvailabilityStatus(availability) {