Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7847e3f69 | ||
|
|
4a63b54630 | ||
|
|
401babb844 | ||
|
|
3500b1a487 | ||
|
|
2cf8856b62 | ||
|
|
abcff9883f | ||
|
|
698642fecb | ||
|
|
d463a5cc30 | ||
|
|
b235d66f81 | ||
|
|
2545e26e8f | ||
|
|
6386142348 | ||
|
|
c750413094 | ||
|
|
f2a7e1da6b | ||
|
|
41c7683e04 | ||
|
|
1b1ba3f8dd | ||
|
|
bd94e5062d | ||
|
|
3fb77fe806 | ||
|
|
c7d259d5fd | ||
|
|
5905b5301d |
@@ -19,7 +19,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- node/install:
|
- node/install:
|
||||||
node-version: '20.12'
|
node-version: '23.7'
|
||||||
- node/install-pnpm
|
- node/install-pnpm
|
||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
pkg-manager: pnpm
|
pkg-manager: pnpm
|
||||||
|
|||||||
@@ -5,30 +5,30 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
base:
|
base:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: .devcontainer/Dockerfile.base
|
dockerfile: .devcontainer/Dockerfile.base
|
||||||
args:
|
args:
|
||||||
VARIANT: "ubuntu-22.04"
|
VARIANT: 'ubuntu-22.04'
|
||||||
NODE_VERSION: "20.9.0"
|
NODE_VERSION: '23.7.0'
|
||||||
RUBY_VERSION: "3.3.3"
|
RUBY_VERSION: '3.3.3'
|
||||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||||
USER_UID: "1000"
|
USER_UID: '1000'
|
||||||
USER_GID: "1000"
|
USER_GID: '1000'
|
||||||
image: base:latest
|
image: base:latest
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: .devcontainer/Dockerfile
|
dockerfile: .devcontainer/Dockerfile
|
||||||
args:
|
args:
|
||||||
VARIANT: "ubuntu-22.04"
|
VARIANT: 'ubuntu-22.04'
|
||||||
NODE_VERSION: "20.9.0"
|
NODE_VERSION: '23.7.0'
|
||||||
RUBY_VERSION: "3.3.3"
|
RUBY_VERSION: '3.3.3'
|
||||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||||
USER_UID: "1000"
|
USER_UID: '1000'
|
||||||
USER_GID: "1000"
|
USER_GID: '1000'
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/workspace:cached
|
- ..:/workspace:cached
|
||||||
|
|||||||
@@ -23,12 +23,10 @@ jobs:
|
|||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 9.3.0
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 23
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install pnpm dependencies
|
- name: Install pnpm dependencies
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 23
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install pnpm dependencies
|
- name: Install pnpm dependencies
|
||||||
|
|||||||
@@ -19,13 +19,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v2
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 9.3.0
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 23
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: pnpm
|
- name: pnpm
|
||||||
@@ -39,7 +37,7 @@ jobs:
|
|||||||
- name: setup env
|
- name: setup env
|
||||||
run: |
|
run: |
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
- name: Run asset compile
|
- name: Run asset compile
|
||||||
run: bundle exec rake assets:precompile
|
run: bundle exec rake assets:precompile
|
||||||
env:
|
env:
|
||||||
@@ -47,5 +45,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Size Check
|
- name: Size Check
|
||||||
run: pnpm run size
|
run: pnpm run size
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
|
|||||||
def index
|
def index
|
||||||
@conversations = Current.account.conversations.includes(
|
@conversations = Current.account.conversations.includes(
|
||||||
:assignee, :contact, :inbox, :taggings
|
:assignee, :contact, :inbox, :taggings
|
||||||
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20)
|
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics]
|
||||||
|
before_action :set_group_scope, only: [:grouped_conversation_metrics]
|
||||||
|
|
||||||
|
def conversation_metrics
|
||||||
|
render json: {
|
||||||
|
open: @conversations.open.count,
|
||||||
|
unattended: @conversations.open.unattended.count,
|
||||||
|
unassigned: @conversations.open.unassigned.count,
|
||||||
|
pending: @conversations.pending.count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def grouped_conversation_metrics
|
||||||
|
count_by_group = @conversations.open.group(@group_scope).count
|
||||||
|
unattended_by_group = @conversations.open.unattended.group(@group_scope).count
|
||||||
|
unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count
|
||||||
|
|
||||||
|
group_metrics = count_by_group.map do |group_id, count|
|
||||||
|
metric = {
|
||||||
|
open: count,
|
||||||
|
unattended: unattended_by_group[group_id] || 0,
|
||||||
|
unassigned: unassigned_by_group[group_id] || 0
|
||||||
|
}
|
||||||
|
metric[@group_scope] = group_id
|
||||||
|
metric
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: group_metrics
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_group_scope
|
||||||
|
render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[
|
||||||
|
team_id
|
||||||
|
assignee_id
|
||||||
|
].include?(permitted_params[:group_by])
|
||||||
|
|
||||||
|
@group_scope = permitted_params[:group_by]
|
||||||
|
end
|
||||||
|
|
||||||
|
def team
|
||||||
|
return unless permitted_params[:team_id]
|
||||||
|
|
||||||
|
@team ||= Current.account.teams.find(permitted_params[:team_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_conversations
|
||||||
|
scope = Current.account.conversations
|
||||||
|
scope = scope.where(team_id: team.id) if team.present?
|
||||||
|
@conversations = scope
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_params
|
||||||
|
params.permit(:team_id, :group_by)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -94,7 +94,8 @@ module Api::V2::Accounts::HeatmapHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def since_timestamp(date)
|
def since_timestamp(date)
|
||||||
(date - 6.days).to_i.to_s
|
number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days
|
||||||
|
(date - number_of_days).to_i.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def until_timestamp(date)
|
def until_timestamp(date)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
class LiveReportsAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('live_reports', { accountScoped: true, apiVersion: 'v2' });
|
||||||
|
}
|
||||||
|
|
||||||
|
getConversationMetric(params = {}) {
|
||||||
|
return axios.get(`${this.url}/conversation_metrics`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getGroupedConversations({ groupBy } = { groupBy: 'assignee_id' }) {
|
||||||
|
return axios.get(`${this.url}/grouped_conversation_metrics`, {
|
||||||
|
params: { group_by: groupBy },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new LiveReportsAPI();
|
||||||
@@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getConversationTrafficCSV() {
|
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
|
||||||
return axios.get(`${this.url}/conversation_traffic`, {
|
return axios.get(`${this.url}/conversation_traffic`, {
|
||||||
params: { timezone_offset: getTimeOffset() },
|
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,26 +14,29 @@ class SearchAPI extends ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
contacts({ q }) {
|
contacts({ q, page = 1 }) {
|
||||||
return axios.get(`${this.url}/contacts`, {
|
return axios.get(`${this.url}/contacts`, {
|
||||||
params: {
|
params: {
|
||||||
q,
|
q,
|
||||||
|
page: page,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
conversations({ q }) {
|
conversations({ q, page = 1 }) {
|
||||||
return axios.get(`${this.url}/conversations`, {
|
return axios.get(`${this.url}/conversations`, {
|
||||||
params: {
|
params: {
|
||||||
q,
|
q,
|
||||||
|
page: page,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
messages({ q }) {
|
messages({ q, page = 1 }) {
|
||||||
return axios.get(`${this.url}/messages`, {
|
return axios.get(`${this.url}/messages`, {
|
||||||
params: {
|
params: {
|
||||||
q,
|
q,
|
||||||
|
page: page,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,10 @@ watch(
|
|||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleClickOutside = () => {
|
||||||
|
showComposeNewConversation.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => resetContacts());
|
onMounted(() => resetContacts());
|
||||||
|
|
||||||
const keyboardEvents = {
|
const keyboardEvents = {
|
||||||
@@ -188,7 +192,12 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-on-click-outside="() => (showComposeNewConversation = false)"
|
v-on-click-outside="[
|
||||||
|
handleClickOutside,
|
||||||
|
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
|
||||||
|
// This will prevent closing the compose conversation modal when the editor Create link popup is open.
|
||||||
|
{ ignore: ['div.ProseMirror-prompt'] },
|
||||||
|
]"
|
||||||
class="relative"
|
class="relative"
|
||||||
:class="{
|
:class="{
|
||||||
'z-40': showComposeNewConversation,
|
'z-40': showComposeNewConversation,
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
labelClass: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['action']);
|
const emit = defineEmits(['action']);
|
||||||
@@ -97,9 +101,13 @@ onMounted(() => {
|
|||||||
</slot>
|
</slot>
|
||||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
|
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
|
||||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||||
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
|
<span
|
||||||
item.label
|
v-if="item.label"
|
||||||
}}</span>
|
class="min-w-0 text-sm truncate"
|
||||||
|
:class="labelClass"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="filteredMenuItems.length === 0"
|
v-if="filteredMenuItems.length === 0"
|
||||||
|
|||||||
@@ -26,7 +26,18 @@ const ccEmail = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const senderName = computed(() => {
|
const senderName = computed(() => {
|
||||||
return sender.value.name ?? '';
|
const fromEmailAddress = fromEmail.value[0] ?? '';
|
||||||
|
const senderEmail = sender.value.email ?? '';
|
||||||
|
|
||||||
|
if (!fromEmailAddress && !senderEmail) return null;
|
||||||
|
|
||||||
|
// if the sender of the conversation and the sender of this particular
|
||||||
|
// email are the same, only then we return the sender name
|
||||||
|
if (fromEmailAddress === senderEmail) {
|
||||||
|
return sender.value.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const bccEmail = computed(() => {
|
const bccEmail = computed(() => {
|
||||||
@@ -59,11 +70,19 @@ const showMeta = computed(() => {
|
|||||||
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
|
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
|
||||||
>
|
>
|
||||||
<template v-if="showMeta">
|
<template v-if="showMeta">
|
||||||
<div v-if="fromEmail[0]">
|
<div
|
||||||
<span :class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'">
|
v-if="fromEmail[0]"
|
||||||
{{ senderName }}
|
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'"
|
||||||
</span>
|
>
|
||||||
<{{ fromEmail[0] }}>
|
<template v-if="senderName">
|
||||||
|
<span>
|
||||||
|
{{ senderName }}
|
||||||
|
</span>
|
||||||
|
<{{ fromEmail[0] }}>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ fromEmail[0] }}
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="toEmail.length">
|
<div v-if="toEmail.length">
|
||||||
{{ $t('EMAIL_HEADER.TO') }}: {{ toEmail.join(', ') }}
|
{{ $t('EMAIL_HEADER.TO') }}: {{ toEmail.join(', ') }}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const isFeatureEnabledonAccount = useMapGetter(
|
|||||||
'accounts/isFeatureEnabledonAccount'
|
'accounts/isFeatureEnabledonAccount'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const globalConfig = useMapGetter('globalConfig/get');
|
||||||
|
|
||||||
const showV4Routes = computed(() => {
|
const showV4Routes = computed(() => {
|
||||||
return isFeatureEnabledonAccount.value(
|
return isFeatureEnabledonAccount.value(
|
||||||
currentAccountId.value,
|
currentAccountId.value,
|
||||||
@@ -525,7 +527,12 @@ const menuItems = computed(() => {
|
|||||||
<section class="grid gap-2 mt-2 mb-4">
|
<section class="grid gap-2 mt-2 mb-4">
|
||||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||||
<div class="grid flex-shrink-0 size-6 place-content-center">
|
<div class="grid flex-shrink-0 size-6 place-content-center">
|
||||||
<Logo />
|
<img
|
||||||
|
v-if="globalConfig.logoThumbnail"
|
||||||
|
:src="globalConfig.logoThumbnail"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
<Logo v-else />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||||
<SidebarAccountSwitcher
|
<SidebarAccountSwitcher
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { ref, onBeforeUnmount } from 'vue';
|
||||||
|
|
||||||
|
export const useLiveRefresh = (callback, interval = 60000) => {
|
||||||
|
const timeoutId = ref(null);
|
||||||
|
|
||||||
|
const startRefetching = () => {
|
||||||
|
timeoutId.value = setTimeout(async () => {
|
||||||
|
await callback();
|
||||||
|
startRefetching();
|
||||||
|
}, interval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRefetching = () => {
|
||||||
|
if (timeoutId.value) {
|
||||||
|
clearTimeout(timeoutId.value);
|
||||||
|
timeoutId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopRefetching();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
startRefetching,
|
||||||
|
stopRefetching,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -29,7 +29,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
'notification.updated': this.onNotificationUpdated,
|
'notification.updated': this.onNotificationUpdated,
|
||||||
'conversation.read': this.onConversationRead,
|
'conversation.read': this.onConversationRead,
|
||||||
'conversation.updated': this.onConversationUpdated,
|
'conversation.updated': this.onConversationUpdated,
|
||||||
'team.changed': this.onTeamChanged,
|
|
||||||
'account.cache_invalidated': this.onCacheInvalidate,
|
'account.cache_invalidated': this.onCacheInvalidate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -115,11 +114,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
this.fetchConversationStats();
|
this.fetchConversationStats();
|
||||||
};
|
};
|
||||||
|
|
||||||
onTeamChanged = data => {
|
|
||||||
this.app.$store.dispatch('updateConversation', data);
|
|
||||||
this.fetchConversationStats();
|
|
||||||
};
|
|
||||||
|
|
||||||
onTypingOn = ({ conversation, user }) => {
|
onTypingOn = ({ conversation, user }) => {
|
||||||
const conversationId = conversation.id;
|
const conversationId = conversation.id;
|
||||||
|
|
||||||
|
|||||||
@@ -476,6 +476,18 @@
|
|||||||
"STATUS": "Status"
|
"STATUS": "Status"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"TEAM_CONVERSATIONS": {
|
||||||
|
"ALL_TEAMS": "All Teams",
|
||||||
|
"HEADER": "Conversations by teams",
|
||||||
|
"LOADING_MESSAGE": "Loading team metrics...",
|
||||||
|
"NO_TEAMS": "There is no data available",
|
||||||
|
"TABLE_HEADER": {
|
||||||
|
"TEAM": "Team",
|
||||||
|
"OPEN": "Open",
|
||||||
|
"UNATTENDED": "Unattended",
|
||||||
|
"STATUS": "Status"
|
||||||
|
}
|
||||||
|
},
|
||||||
"AGENT_STATUS": {
|
"AGENT_STATUS": {
|
||||||
"HEADER": "Agent status",
|
"HEADER": "Agent status",
|
||||||
"ONLINE": "Online",
|
"ONLINE": "Online",
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
"CONVERSATIONS": "Conversations",
|
"CONVERSATIONS": "Conversations",
|
||||||
"MESSAGES": "Messages"
|
"MESSAGES": "Messages"
|
||||||
},
|
},
|
||||||
|
"VIEW_MORE": "View more",
|
||||||
|
"LOAD_MORE": "Load more",
|
||||||
"SEARCHING_DATA": "Searching",
|
"SEARCHING_DATA": "Searching",
|
||||||
|
"LOADING_DATA": "Loading",
|
||||||
"EMPTY_STATE": "No {item} found for query '{query}'",
|
"EMPTY_STATE": "No {item} found for query '{query}'",
|
||||||
"EMPTY_STATE_FULL": "No results found for query '{query}'",
|
"EMPTY_STATE_FULL": "No results found for query '{query}'",
|
||||||
"PLACEHOLDER_KEYBINDING": "/ to focus",
|
"PLACEHOLDER_KEYBINDING": "/ to focus",
|
||||||
|
|||||||
@@ -1,73 +1,59 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { ref, useTemplateRef, onMounted, watch, nextTick } from 'vue';
|
||||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||||
import ReadMore from './ReadMore.vue';
|
import ReadMore from './ReadMore.vue';
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
components: {
|
author: {
|
||||||
ReadMore,
|
type: String,
|
||||||
|
default: '',
|
||||||
},
|
},
|
||||||
props: {
|
content: {
|
||||||
author: {
|
type: String,
|
||||||
type: String,
|
default: '',
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
searchTerm: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
setup() {
|
searchTerm: {
|
||||||
const { formatMessage, highlightContent } = useMessageFormatter();
|
type: String,
|
||||||
return {
|
default: '',
|
||||||
formatMessage,
|
|
||||||
highlightContent,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
data() {
|
});
|
||||||
return {
|
|
||||||
isOverflowing: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
messageContent() {
|
|
||||||
return this.formatMessage(this.content);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$watch(() => {
|
|
||||||
return this.$refs.messageContainer;
|
|
||||||
}, this.setOverflow);
|
|
||||||
|
|
||||||
this.$nextTick(this.setOverflow);
|
const { highlightContent } = useMessageFormatter();
|
||||||
},
|
|
||||||
methods: {
|
const messageContainer = useTemplateRef('messageContainer');
|
||||||
setOverflow() {
|
const isOverflowing = ref(false);
|
||||||
const wrap = this.$refs.messageContainer;
|
|
||||||
if (wrap) {
|
const setOverflow = () => {
|
||||||
const message = wrap.querySelector('.message-content');
|
const wrap = messageContainer.value;
|
||||||
this.isOverflowing = message.offsetHeight > 150;
|
if (wrap) {
|
||||||
}
|
const message = wrap.querySelector('.message-content');
|
||||||
},
|
isOverflowing.value = message.offsetHeight > 150;
|
||||||
escapeHtml(html) {
|
}
|
||||||
var text = document.createTextNode(html);
|
|
||||||
var p = document.createElement('p');
|
|
||||||
p.appendChild(text);
|
|
||||||
return p.innerText;
|
|
||||||
},
|
|
||||||
prepareContent(content = '') {
|
|
||||||
const escapedText = this.escapeHtml(content);
|
|
||||||
return this.highlightContent(
|
|
||||||
escapedText,
|
|
||||||
this.searchTerm,
|
|
||||||
'searchkey--highlight'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeHtml = html => {
|
||||||
|
var text = document.createTextNode(html);
|
||||||
|
var p = document.createElement('p');
|
||||||
|
p.appendChild(text);
|
||||||
|
return p.innerText;
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareContent = (content = '') => {
|
||||||
|
const escapedText = escapeHtml(content);
|
||||||
|
return highlightContent(
|
||||||
|
escapedText,
|
||||||
|
props.searchTerm,
|
||||||
|
'searchkey--highlight'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
watch(() => {
|
||||||
|
return messageContainer.value;
|
||||||
|
}, setOverflow);
|
||||||
|
|
||||||
|
nextTick(setOverflow);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
shrink: {
|
shrink: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -18,17 +20,18 @@ defineEmits(['expand']);
|
|||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
<div
|
<div
|
||||||
class="bg-n-slate-3 rounded-md dark:bg-n-solid-3 absolute left-0 right-0 z-20 mx-auto mt-0 max-w-max bottom-2 backdrop-blur[100px]"
|
v-if="shrink"
|
||||||
|
class="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t to-transparent from-n-background flex items-end justify-center pb-2"
|
||||||
>
|
>
|
||||||
<woot-button
|
<NextButton
|
||||||
v-if="shrink"
|
:label="$t('SEARCH.READ_MORE')"
|
||||||
size="tiny"
|
icon="i-lucide-chevrons-down"
|
||||||
variant="smooth"
|
blue
|
||||||
color-scheme="primary"
|
xs
|
||||||
|
faded
|
||||||
|
class="backdrop-filter backdrop-blur-[2px]"
|
||||||
@click.prevent="$emit('expand')"
|
@click.prevent="$emit('expand')"
|
||||||
>
|
/>
|
||||||
{{ $t('SEARCH.READ_MORE') }}
|
|
||||||
</woot-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,52 +1,51 @@
|
|||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref, useTemplateRef, onMounted, onUnmounted } from 'vue';
|
||||||
emits: ['search'],
|
import { debounce } from '@chatwoot/utils';
|
||||||
|
|
||||||
data() {
|
const emit = defineEmits(['search']);
|
||||||
return {
|
|
||||||
searchQuery: '',
|
const searchQuery = ref('');
|
||||||
isInputFocused: false,
|
const isInputFocused = ref(false);
|
||||||
};
|
|
||||||
},
|
const searchInput = useTemplateRef('searchInput');
|
||||||
mounted() {
|
|
||||||
this.$refs.searchInput.focus();
|
const handler = e => {
|
||||||
document.addEventListener('keydown', this.handler);
|
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
|
||||||
},
|
e.preventDefault();
|
||||||
unmounted() {
|
searchInput.value.focus();
|
||||||
document.removeEventListener('keydown', this.handler);
|
} else if (e.key === 'Escape' && document.activeElement.tagName === 'INPUT') {
|
||||||
},
|
e.preventDefault();
|
||||||
methods: {
|
searchInput.value.blur();
|
||||||
handler(e) {
|
}
|
||||||
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.$refs.searchInput.focus();
|
|
||||||
} else if (
|
|
||||||
e.key === 'Escape' &&
|
|
||||||
document.activeElement.tagName === 'INPUT'
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.$refs.searchInput.blur();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
debounceSearch(e) {
|
|
||||||
this.searchQuery = e.target.value;
|
|
||||||
clearTimeout(this.debounce);
|
|
||||||
this.debounce = setTimeout(async () => {
|
|
||||||
if (this.searchQuery.length > 2 || this.searchQuery.match(/^[0-9]+$/)) {
|
|
||||||
this.$emit('search', this.searchQuery);
|
|
||||||
} else {
|
|
||||||
this.$emit('search', '');
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
},
|
|
||||||
onFocus() {
|
|
||||||
this.isInputFocused = true;
|
|
||||||
},
|
|
||||||
onBlur() {
|
|
||||||
this.isInputFocused = false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const debouncedEmit = debounce(
|
||||||
|
value =>
|
||||||
|
emit('search', value.length > 1 || value.match(/^[0-9]+$/) ? value : ''),
|
||||||
|
500
|
||||||
|
);
|
||||||
|
|
||||||
|
const onInput = e => {
|
||||||
|
searchQuery.value = e.target.value;
|
||||||
|
debouncedEmit(searchQuery.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
isInputFocused.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlur = () => {
|
||||||
|
isInputFocused.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
searchInput.value.focus();
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handler);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -76,7 +75,7 @@ export default {
|
|||||||
:value="searchQuery"
|
:value="searchQuery"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
@input="debounceSearch"
|
@input="onInput"
|
||||||
/>
|
/>
|
||||||
<woot-label
|
<woot-label
|
||||||
:title="$t('SEARCH.PLACEHOLDER_KEYBINDING')"
|
:title="$t('SEARCH.PLACEHOLDER_KEYBINDING')"
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="search-input-box">
|
|
||||||
<woot-button
|
|
||||||
class="hollow"
|
|
||||||
size="small"
|
|
||||||
color-scheme="secondary"
|
|
||||||
is-expanded
|
|
||||||
>
|
|
||||||
<div class="search-input">
|
|
||||||
<fluent-icon icon="search" size="14px" class="search--icon" />
|
|
||||||
<span
|
|
||||||
class="overflow-hidden text-ellipsis whitespace-nowrap search-placeholder"
|
|
||||||
>
|
|
||||||
{{ $t('CONVERSATION.SEARCH_MESSAGES') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</woot-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.search-input-box {
|
|
||||||
@apply p-2;
|
|
||||||
}
|
|
||||||
.search--icon {
|
|
||||||
@apply flex-shrink-0 text-slate-500 dark:text-slate-300;
|
|
||||||
}
|
|
||||||
.search-placeholder {
|
|
||||||
@apply text-slate-500 dark:text-slate-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
@apply flex justify-center items-center gap-1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||||
|
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -37,28 +39,27 @@ const navigateTo = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<router-link
|
<router-link
|
||||||
:to="navigateTo"
|
:to="navigateTo"
|
||||||
class="flex items-center p-2 rounded-md cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3"
|
class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
|
||||||
>
|
>
|
||||||
<woot-thumbnail :src="thumbnail" :username="name" size="24px" />
|
<Avatar
|
||||||
<div class="ml-2 rtl:mr-2 rtl:ml-0">
|
:name="name"
|
||||||
<h5 class="text-sm name text-n-slate-12 dark:text-n-slate-12">
|
:src="thumbnail"
|
||||||
|
:size="24"
|
||||||
|
rounded-full
|
||||||
|
class="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div class="ml-2 rtl:mr-2 min-w-0 rtl:ml-0">
|
||||||
|
<h5 class="text-sm name truncate min-w-0 text-n-slate-12">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</h5>
|
</h5>
|
||||||
<p
|
<p
|
||||||
class="flex items-center gap-1 m-0 text-sm text-slate-600 dark:text-slate-200"
|
class="grid items-center m-0 gap-1 text-sm grid-cols-[minmax(0,1fr)_auto_auto]"
|
||||||
>
|
>
|
||||||
<span v-if="email" class="email text-n-slate-12 dark:text-n-slate-12">{{
|
<span v-if="email" class="truncate text-n-slate-12" :title="email">
|
||||||
email
|
{{ email }}
|
||||||
}}</span>
|
|
||||||
<span
|
|
||||||
v-if="phone"
|
|
||||||
class="separator text-n-slate-10 dark:text-n-slate-10"
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</span>
|
|
||||||
<span v-if="phone" class="phone text-n-slate-12 dark:text-n-slate-12">
|
|
||||||
{{ phone }}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="phone" class="text-n-slate-10">•</span>
|
||||||
|
<span v-if="phone" class="text-n-slate-12">{{ phone }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|||||||
@@ -1,38 +1,29 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import { mapGetters } from 'vuex';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
import SearchResultSection from './SearchResultSection.vue';
|
import SearchResultSection from './SearchResultSection.vue';
|
||||||
import SearchResultContactItem from './SearchResultContactItem.vue';
|
import SearchResultContactItem from './SearchResultContactItem.vue';
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
components: {
|
contacts: {
|
||||||
SearchResultSection,
|
type: Array,
|
||||||
SearchResultContactItem,
|
default: () => [],
|
||||||
},
|
},
|
||||||
props: {
|
query: {
|
||||||
contacts: {
|
type: String,
|
||||||
type: Array,
|
default: '',
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
isFetching: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
showTitle: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
computed: {
|
isFetching: {
|
||||||
...mapGetters({
|
type: Boolean,
|
||||||
accountId: 'getCurrentAccountId',
|
default: false,
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
};
|
showTitle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountId = useMapGetter('getCurrentAccountId');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -43,7 +34,7 @@ export default {
|
|||||||
:show-title="showTitle"
|
:show-title="showTitle"
|
||||||
:is-fetching="isFetching"
|
:is-fetching="isFetching"
|
||||||
>
|
>
|
||||||
<ul v-if="contacts.length" class="space-y-1.5">
|
<ul v-if="contacts.length" class="space-y-1.5 list-none">
|
||||||
<li v-for="contact in contacts" :key="contact.id">
|
<li v-for="contact in contacts" :key="contact.id">
|
||||||
<SearchResultContactItem
|
<SearchResultContactItem
|
||||||
:id="contact.id"
|
:id="contact.id"
|
||||||
|
|||||||
+60
-67
@@ -1,72 +1,69 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
||||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||||
import InboxName from 'dashboard/components/widgets/InboxName.vue';
|
import InboxName from 'dashboard/components/widgets/InboxName.vue';
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
components: {
|
id: {
|
||||||
InboxName,
|
type: Number,
|
||||||
|
default: 0,
|
||||||
},
|
},
|
||||||
props: {
|
inbox: {
|
||||||
id: {
|
type: Object,
|
||||||
type: Number,
|
default: () => ({}),
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
inbox: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
accountId: {
|
|
||||||
type: [String, Number],
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
createdAt: {
|
|
||||||
type: [String, Date, Number],
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
messageId: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
computed: {
|
name: {
|
||||||
navigateTo() {
|
type: String,
|
||||||
const params = {};
|
default: '',
|
||||||
if (this.messageId) {
|
|
||||||
params.messageId = this.messageId;
|
|
||||||
}
|
|
||||||
return frontendURL(
|
|
||||||
`accounts/${this.accountId}/conversations/${this.id}`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
},
|
|
||||||
createdAtTime() {
|
|
||||||
return dynamicTime(this.createdAt);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
email: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
accountId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: [String, Date, Number],
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
messageId: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigateTo = computed(() => {
|
||||||
|
const params = {};
|
||||||
|
if (props.messageId) {
|
||||||
|
params.messageId = props.messageId;
|
||||||
|
}
|
||||||
|
return frontendURL(
|
||||||
|
`accounts/${props.accountId}/conversations/${props.id}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdAtTime = dynamicTime(props.createdAt);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-link
|
<router-link
|
||||||
:to="navigateTo"
|
:to="navigateTo"
|
||||||
class="flex p-2 rounded-md cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3"
|
class="flex p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
|
||||||
>
|
>
|
||||||
<div
|
<Avatar
|
||||||
class="flex items-center justify-center flex-shrink-0 w-6 h-6 rounded bg-n-brand/10 dark:bg-n-brand/40 text-n-blue-text dark:text-n-blue-text"
|
name="chats"
|
||||||
>
|
:size="24"
|
||||||
<fluent-icon icon="chat-multiple" :size="14" />
|
icon-name="i-lucide-messages-square"
|
||||||
</div>
|
class="[&>span]:rounded"
|
||||||
|
/>
|
||||||
<div class="flex-grow min-w-0 ml-2">
|
<div class="flex-grow min-w-0 ml-2">
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center min-w-0 justify-between gap-1 mb-1">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<woot-label
|
<woot-label
|
||||||
class="!bg-n-slate-3 dark:!bg-n-solid-3 !border-n-weak dark:!border-n-strong m-0"
|
class="!bg-n-slate-3 dark:!bg-n-solid-3 !border-n-weak dark:!border-n-strong m-0"
|
||||||
@@ -83,29 +80,25 @@ export default {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<span
|
||||||
<span
|
class="text-xs font-normal min-w-0 truncate text-n-slate-11 dark:text-n-slate-11"
|
||||||
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
|
>
|
||||||
>
|
{{ createdAtTime }}
|
||||||
{{ createdAtTime }}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<h5
|
<h5
|
||||||
v-if="name"
|
v-if="name"
|
||||||
class="m-0 text-sm text-n-slate-12 dark:text-n-slate-12"
|
class="m-0 text-sm min-w-0 truncate text-n-slate-12 dark:text-n-slate-12"
|
||||||
>
|
>
|
||||||
<span
|
<span class="text-xs font-norma text-n-slate-11 dark:text-n-slate-11">
|
||||||
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
|
|
||||||
>
|
|
||||||
{{ $t('SEARCH.FROM') }}:
|
{{ $t('SEARCH.FROM') }}:
|
||||||
</span>
|
</span>
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</h5>
|
</h5>
|
||||||
<h5
|
<h5
|
||||||
v-if="email"
|
v-if="email"
|
||||||
class="m-0 overflow-hidden text-sm text-n-slate-12 dark:text-n-slate-12 whitespace-nowrap text-ellipsis"
|
class="m-0 overflow-hidden text-sm text-n-slate-12 dark:text-n-slate-12 truncate"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
|
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
|
||||||
|
|||||||
+21
-30
@@ -1,37 +1,28 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import { mapGetters } from 'vuex';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import SearchResultSection from './SearchResultSection.vue';
|
import SearchResultSection from './SearchResultSection.vue';
|
||||||
import SearchResultConversationItem from './SearchResultConversationItem.vue';
|
import SearchResultConversationItem from './SearchResultConversationItem.vue';
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
components: {
|
conversations: {
|
||||||
SearchResultSection,
|
type: Array,
|
||||||
SearchResultConversationItem,
|
default: () => [],
|
||||||
},
|
},
|
||||||
props: {
|
query: {
|
||||||
conversations: {
|
type: String,
|
||||||
type: Array,
|
default: '',
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
isFetching: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
showTitle: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
computed: {
|
isFetching: {
|
||||||
...mapGetters({
|
type: Boolean,
|
||||||
accountId: 'getCurrentAccountId',
|
default: false,
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
};
|
showTitle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountId = useMapGetter('getCurrentAccountId');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -39,10 +30,10 @@ export default {
|
|||||||
:title="$t('SEARCH.SECTION.CONVERSATIONS')"
|
:title="$t('SEARCH.SECTION.CONVERSATIONS')"
|
||||||
:empty="!conversations.length"
|
:empty="!conversations.length"
|
||||||
:query="query"
|
:query="query"
|
||||||
:show-title="showTitle || isFetching"
|
:show-title="showTitle"
|
||||||
:is-fetching="isFetching"
|
:is-fetching="isFetching"
|
||||||
>
|
>
|
||||||
<ul v-if="conversations.length" class="space-y-1.5">
|
<ul v-if="conversations.length" class="space-y-1.5 list-none">
|
||||||
<li v-for="conversation in conversations" :key="conversation.id">
|
<li v-for="conversation in conversations" :key="conversation.id">
|
||||||
<SearchResultConversationItem
|
<SearchResultConversationItem
|
||||||
:id="conversation.id"
|
:id="conversation.id"
|
||||||
|
|||||||
@@ -1,45 +1,37 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import { mapGetters } from 'vuex';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
import SearchResultConversationItem from './SearchResultConversationItem.vue';
|
import SearchResultConversationItem from './SearchResultConversationItem.vue';
|
||||||
import SearchResultSection from './SearchResultSection.vue';
|
import SearchResultSection from './SearchResultSection.vue';
|
||||||
import MessageContent from './MessageContent.vue';
|
import MessageContent from './MessageContent.vue';
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
components: {
|
messages: {
|
||||||
SearchResultConversationItem,
|
type: Array,
|
||||||
SearchResultSection,
|
default: () => [],
|
||||||
MessageContent,
|
|
||||||
},
|
},
|
||||||
props: {
|
query: {
|
||||||
messages: {
|
type: String,
|
||||||
type: Array,
|
default: '',
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
isFetching: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
showTitle: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
computed: {
|
isFetching: {
|
||||||
...mapGetters({
|
type: Boolean,
|
||||||
accountId: 'getCurrentAccountId',
|
default: false,
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
methods: {
|
showTitle: {
|
||||||
getName(message) {
|
type: Boolean,
|
||||||
return message && message.sender && message.sender.name
|
default: true,
|
||||||
? message.sender.name
|
|
||||||
: this.$t('SEARCH.BOT_LABEL');
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const accountId = useMapGetter('getCurrentAccountId');
|
||||||
|
|
||||||
|
const getName = message => {
|
||||||
|
return message && message.sender && message.sender.name
|
||||||
|
? message.sender.name
|
||||||
|
: t('SEARCH.BOT_LABEL');
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -51,7 +43,7 @@ export default {
|
|||||||
:show-title="showTitle"
|
:show-title="showTitle"
|
||||||
:is-fetching="isFetching"
|
:is-fetching="isFetching"
|
||||||
>
|
>
|
||||||
<ul v-if="messages.length" class="space-y-1.5">
|
<ul v-if="messages.length" class="space-y-1.5 list-none">
|
||||||
<li v-for="message in messages" :key="message.id">
|
<li v-for="message in messages" :key="message.id">
|
||||||
<SearchResultConversationItem
|
<SearchResultConversationItem
|
||||||
:id="message.conversation_id"
|
:id="message.conversation_id"
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ const titleCase = computed(() => props.title.toLowerCase());
|
|||||||
<div v-if="showTitle" class="sticky top-0 p-2 z-50 mb-0.5 bg-n-background">
|
<div v-if="showTitle" class="sticky top-0 p-2 z-50 mb-0.5 bg-n-background">
|
||||||
<h3 class="text-sm text-n-slate-12">{{ title }}</h3>
|
<h3 class="text-sm text-n-slate-12">{{ title }}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<slot />
|
||||||
<woot-loading-state
|
<woot-loading-state
|
||||||
v-if="isFetching"
|
v-if="isFetching"
|
||||||
:message="$t('SEARCH.SEARCHING_DATA')"
|
:message="empty ? $t('SEARCH.SEARCHING_DATA') : $t('SEARCH.LOADING_DATA')"
|
||||||
/>
|
/>
|
||||||
<slot v-else />
|
|
||||||
<div
|
<div
|
||||||
v-if="empty && !isFetching"
|
v-if="empty && !isFetching"
|
||||||
class="flex items-center justify-center px-4 py-6 m-2 rounded-md bg-n-slate-2 dark:bg-n-solid-3"
|
class="flex items-center justify-center px-4 py-6 m-2 rounded-xl bg-n-slate-2 dark:bg-n-solid-1"
|
||||||
>
|
>
|
||||||
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
|
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
|
||||||
<p class="mx-2 my-0 text-center text-n-slate-11">
|
<p class="mx-2 my-0 text-center text-n-slate-11">
|
||||||
|
|||||||
@@ -1,39 +1,38 @@
|
|||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref, watch } from 'vue';
|
||||||
props: {
|
|
||||||
tabs: {
|
const props = defineProps({
|
||||||
type: Array,
|
tabs: {
|
||||||
default: () => [],
|
type: Array,
|
||||||
},
|
default: () => [],
|
||||||
selectedTab: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
emits: ['tabChange'],
|
selectedTab: {
|
||||||
data() {
|
type: Number,
|
||||||
return {
|
default: 0,
|
||||||
activeTab: 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
selectedTab(value, oldValue) {
|
|
||||||
if (value !== oldValue) {
|
|
||||||
this.activeTab = this.selectedTab;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onTabChange(index) {
|
|
||||||
this.activeTab = index;
|
|
||||||
this.$emit('tabChange', this.tabs[index].key);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['tabChange']);
|
||||||
|
|
||||||
|
const activeTab = ref(props.selectedTab);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.selectedTab,
|
||||||
|
(value, oldValue) => {
|
||||||
|
if (value !== oldValue) {
|
||||||
|
activeTab.value = props.selectedTab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTabChange = index => {
|
||||||
|
activeTab.value = index;
|
||||||
|
emit('tabChange', props.tabs[index].key);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tab-container">
|
<div class="mt-1 border-b border-n-weak">
|
||||||
<woot-tabs :index="activeTab" :border="false" @change="onTabChange">
|
<woot-tabs :index="activeTab" :border="false" @change="onTabChange">
|
||||||
<woot-tabs-item
|
<woot-tabs-item
|
||||||
v-for="(item, index) in tabs"
|
v-for="(item, index) in tabs"
|
||||||
@@ -41,13 +40,8 @@ export default {
|
|||||||
:index="index"
|
:index="index"
|
||||||
:name="item.name"
|
:name="item.name"
|
||||||
:count="item.count"
|
:count="item.count"
|
||||||
|
:show-badge="item.showBadge"
|
||||||
/>
|
/>
|
||||||
</woot-tabs>
|
</woot-tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.tab-container {
|
|
||||||
@apply mt-1 border-b border-solid border-slate-100 dark:border-slate-800/50;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import SearchHeader from './SearchHeader.vue';
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import SearchTabs from './SearchTabs.vue';
|
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||||
import SearchResultConversationsList from './SearchResultConversationsList.vue';
|
import { useRouter } from 'vue-router';
|
||||||
import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
|
||||||
import SearchResultContactsList from './SearchResultContactsList.vue';
|
|
||||||
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import {
|
||||||
ROLES,
|
ROLES,
|
||||||
CONVERSATION_PERMISSIONS,
|
CONVERSATION_PERMISSIONS,
|
||||||
@@ -16,197 +13,233 @@ import {
|
|||||||
getUserPermissions,
|
getUserPermissions,
|
||||||
filterItemsByPermission,
|
filterItemsByPermission,
|
||||||
} from 'dashboard/helper/permissionsHelper.js';
|
} from 'dashboard/helper/permissionsHelper.js';
|
||||||
|
|
||||||
import { mapGetters } from 'vuex';
|
|
||||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
|
|
||||||
export default {
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
components: {
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
SearchHeader,
|
import SearchHeader from './SearchHeader.vue';
|
||||||
SearchTabs,
|
import SearchTabs from './SearchTabs.vue';
|
||||||
SearchResultContactsList,
|
import SearchResultConversationsList from './SearchResultConversationsList.vue';
|
||||||
SearchResultConversationsList,
|
import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
||||||
SearchResultMessagesList,
|
import SearchResultContactsList from './SearchResultContactsList.vue';
|
||||||
Policy,
|
|
||||||
ButtonV4,
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
selectedTab: 'all',
|
|
||||||
query: '',
|
|
||||||
contactPermissions: CONTACT_PERMISSIONS,
|
|
||||||
conversationPermissions: CONVERSATION_PERMISSIONS,
|
|
||||||
rolePermissions: ROLES,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
const router = useRouter();
|
||||||
...mapGetters({
|
const store = useStore();
|
||||||
currentUser: 'getCurrentUser',
|
const { t } = useI18n();
|
||||||
currentAccountId: 'getCurrentAccountId',
|
|
||||||
contactRecords: 'conversationSearch/getContactRecords',
|
|
||||||
conversationRecords: 'conversationSearch/getConversationRecords',
|
|
||||||
messageRecords: 'conversationSearch/getMessageRecords',
|
|
||||||
uiFlags: 'conversationSearch/getUIFlags',
|
|
||||||
}),
|
|
||||||
contacts() {
|
|
||||||
return this.contactRecords.map(contact => ({
|
|
||||||
...contact,
|
|
||||||
type: 'contact',
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
conversations() {
|
|
||||||
return this.conversationRecords.map(conversation => ({
|
|
||||||
...conversation,
|
|
||||||
type: 'conversation',
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
messages() {
|
|
||||||
return this.messageRecords.map(message => ({
|
|
||||||
...message,
|
|
||||||
type: 'message',
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
all() {
|
|
||||||
return [...this.contacts, ...this.conversations, ...this.messages];
|
|
||||||
},
|
|
||||||
filterContacts() {
|
|
||||||
return this.selectedTab === 'contacts' || this.isSelectedTabAll;
|
|
||||||
},
|
|
||||||
filterConversations() {
|
|
||||||
return this.selectedTab === 'conversations' || this.isSelectedTabAll;
|
|
||||||
},
|
|
||||||
filterMessages() {
|
|
||||||
return this.selectedTab === 'messages' || this.isSelectedTabAll;
|
|
||||||
},
|
|
||||||
userPermissions() {
|
|
||||||
return getUserPermissions(this.currentUser, this.currentAccountId);
|
|
||||||
},
|
|
||||||
totalSearchResultsCount() {
|
|
||||||
const permissionCounts = {
|
|
||||||
contacts: {
|
|
||||||
permissions: [...this.rolePermissions, this.contactPermissions],
|
|
||||||
count: () => this.contacts.length,
|
|
||||||
},
|
|
||||||
conversations: {
|
|
||||||
permissions: [
|
|
||||||
...this.rolePermissions,
|
|
||||||
...this.conversationPermissions,
|
|
||||||
],
|
|
||||||
count: () => this.conversations.length + this.messages.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredCounts = filterItemsByPermission(
|
const PER_PAGE = 15; // Results per page
|
||||||
permissionCounts,
|
const selectedTab = ref('all');
|
||||||
this.userPermissions,
|
const query = ref('');
|
||||||
item => item.permissions,
|
const pages = ref({
|
||||||
(_, item) => item.count
|
contacts: 1,
|
||||||
);
|
conversations: 1,
|
||||||
|
messages: 1,
|
||||||
|
});
|
||||||
|
|
||||||
return filteredCounts.reduce((total, count) => total + count(), 0);
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
},
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
tabs() {
|
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
|
||||||
const allTabsConfig = {
|
const conversationRecords = useMapGetter(
|
||||||
all: {
|
'conversationSearch/getConversationRecords'
|
||||||
key: 'all',
|
);
|
||||||
name: this.$t('SEARCH.TABS.ALL'),
|
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
|
||||||
count: this.totalSearchResultsCount,
|
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
|
||||||
permissions: [
|
|
||||||
this.contactPermissions,
|
|
||||||
...this.rolePermissions,
|
|
||||||
...this.conversationPermissions,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
contacts: {
|
|
||||||
key: 'contacts',
|
|
||||||
name: this.$t('SEARCH.TABS.CONTACTS'),
|
|
||||||
count: this.contacts.length,
|
|
||||||
permissions: [...this.rolePermissions, this.contactPermissions],
|
|
||||||
},
|
|
||||||
conversations: {
|
|
||||||
key: 'conversations',
|
|
||||||
name: this.$t('SEARCH.TABS.CONVERSATIONS'),
|
|
||||||
count: this.conversations.length,
|
|
||||||
permissions: [
|
|
||||||
...this.rolePermissions,
|
|
||||||
...this.conversationPermissions,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
key: 'messages',
|
|
||||||
name: this.$t('SEARCH.TABS.MESSAGES'),
|
|
||||||
count: this.messages.length,
|
|
||||||
permissions: [
|
|
||||||
...this.rolePermissions,
|
|
||||||
...this.conversationPermissions,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return filterItemsByPermission(
|
const addTypeToRecords = (records, type) =>
|
||||||
allTabsConfig,
|
records.value.map(item => ({ ...item, type }));
|
||||||
this.userPermissions,
|
|
||||||
item => item.permissions
|
const mappedContacts = computed(() =>
|
||||||
);
|
addTypeToRecords(contactRecords, 'contact')
|
||||||
},
|
);
|
||||||
activeTabIndex() {
|
const mappedConversations = computed(() =>
|
||||||
const index = this.tabs.findIndex(tab => tab.key === this.selectedTab);
|
addTypeToRecords(conversationRecords, 'conversation')
|
||||||
return index >= 0 ? index : 0;
|
);
|
||||||
},
|
const mappedMessages = computed(() =>
|
||||||
showEmptySearchResults() {
|
addTypeToRecords(messageRecords, 'message')
|
||||||
return (
|
);
|
||||||
this.totalSearchResultsCount === 0 &&
|
|
||||||
this.uiFlags.isSearchCompleted &&
|
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
|
||||||
!this.uiFlags.isFetching &&
|
|
||||||
this.query
|
const sliceRecordsIfAllTab = items =>
|
||||||
);
|
isSelectedTabAll.value ? items.value.slice(0, 5) : items.value;
|
||||||
},
|
|
||||||
showResultsSection() {
|
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
|
||||||
return (
|
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
|
||||||
(this.uiFlags.isSearchCompleted &&
|
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
|
||||||
this.totalSearchResultsCount !== 0) ||
|
|
||||||
this.uiFlags.isFetching
|
const filterByTab = tab =>
|
||||||
);
|
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
|
||||||
},
|
|
||||||
isSelectedTabAll() {
|
const filterContacts = filterByTab('contacts');
|
||||||
return this.selectedTab === 'all';
|
const filterConversations = filterByTab('conversations');
|
||||||
},
|
const filterMessages = filterByTab('messages');
|
||||||
|
|
||||||
|
const userPermissions = computed(() =>
|
||||||
|
getUserPermissions(currentUser.value, currentAccountId.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const TABS_CONFIG = {
|
||||||
|
all: {
|
||||||
|
permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
|
count: () => null, // No count for all tab
|
||||||
},
|
},
|
||||||
unmounted() {
|
contacts: {
|
||||||
this.query = '';
|
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
||||||
this.$store.dispatch('conversationSearch/clearSearchResults');
|
count: () => mappedContacts.value.length,
|
||||||
},
|
},
|
||||||
mounted() {
|
conversations: {
|
||||||
this.$store.dispatch('conversationSearch/clearSearchResults');
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
|
count: () => mappedConversations.value.length,
|
||||||
},
|
},
|
||||||
methods: {
|
messages: {
|
||||||
onSearch(q) {
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
this.selectedTab = 'all';
|
count: () => mappedMessages.value.length,
|
||||||
this.query = q;
|
|
||||||
if (!q) {
|
|
||||||
this.$store.dispatch('conversationSearch/clearSearchResults');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
|
|
||||||
this.$store.dispatch('conversationSearch/fullSearch', { q });
|
|
||||||
},
|
|
||||||
onBack() {
|
|
||||||
if (window.history.length > 2) {
|
|
||||||
this.$router.go(-1);
|
|
||||||
} else {
|
|
||||||
this.$router.push({ name: 'home' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
const configs = Object.entries(TABS_CONFIG).map(([key, config]) => ({
|
||||||
|
key,
|
||||||
|
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
|
||||||
|
count: config.count(),
|
||||||
|
showBadge: key !== 'all',
|
||||||
|
permissions: config.permissions,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return filterItemsByPermission(
|
||||||
|
configs,
|
||||||
|
userPermissions.value,
|
||||||
|
item => item.permissions
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalSearchResultsCount = computed(() => {
|
||||||
|
const permissionCounts = {
|
||||||
|
contacts: {
|
||||||
|
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
||||||
|
count: () => contacts.value.length,
|
||||||
|
},
|
||||||
|
conversations: {
|
||||||
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
|
count: () => conversations.value.length + messages.value.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return filterItemsByPermission(
|
||||||
|
permissionCounts,
|
||||||
|
userPermissions.value,
|
||||||
|
item => item.permissions,
|
||||||
|
(_, item) => item.count
|
||||||
|
).reduce((total, count) => total + count(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTabIndex = computed(() => {
|
||||||
|
const index = tabs.value.findIndex(tab => tab.key === selectedTab.value);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFetchingAny = computed(() => {
|
||||||
|
const { contact, message, conversation, isFetching } = uiFlags.value;
|
||||||
|
return (
|
||||||
|
isFetching ||
|
||||||
|
contact.isFetching ||
|
||||||
|
message.isFetching ||
|
||||||
|
conversation.isFetching
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showEmptySearchResults = computed(
|
||||||
|
() =>
|
||||||
|
totalSearchResultsCount.value === 0 &&
|
||||||
|
uiFlags.value.isSearchCompleted &&
|
||||||
|
isSelectedTabAll.value &&
|
||||||
|
!isFetchingAny.value &&
|
||||||
|
query.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const showResultsSection = computed(
|
||||||
|
() =>
|
||||||
|
(uiFlags.value.isSearchCompleted && totalSearchResultsCount.value !== 0) ||
|
||||||
|
isFetchingAny.value ||
|
||||||
|
(!isSelectedTabAll.value && query.value && !isFetchingAny.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const showLoadMore = computed(() => {
|
||||||
|
if (!query.value || isFetchingAny.value || selectedTab.value === 'all')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const records = {
|
||||||
|
contacts: mappedContacts.value,
|
||||||
|
conversations: mappedConversations.value,
|
||||||
|
messages: mappedMessages.value,
|
||||||
|
}[selectedTab.value];
|
||||||
|
|
||||||
|
return (
|
||||||
|
records?.length > 0 &&
|
||||||
|
records.length === pages.value[selectedTab.value] * PER_PAGE
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showViewMore = computed(() => ({
|
||||||
|
// Hide view more button if the number of records is less than 5
|
||||||
|
contacts: mappedContacts.value?.length > 5 && isSelectedTabAll.value,
|
||||||
|
conversations:
|
||||||
|
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
|
||||||
|
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clearSearchResult = () => {
|
||||||
|
pages.value = { contacts: 1, conversations: 1, messages: 1 };
|
||||||
|
store.dispatch('conversationSearch/clearSearchResults');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearch = q => {
|
||||||
|
query.value = q;
|
||||||
|
clearSearchResult();
|
||||||
|
if (!q) return;
|
||||||
|
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
|
||||||
|
store.dispatch('conversationSearch/fullSearch', { q, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBack = () => {
|
||||||
|
if (window.history.length > 2) {
|
||||||
|
router.go(-1);
|
||||||
|
} else {
|
||||||
|
router.push({ name: 'home' });
|
||||||
|
}
|
||||||
|
clearSearchResult();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
const SEARCH_ACTIONS = {
|
||||||
|
contacts: 'conversationSearch/contactSearch',
|
||||||
|
conversations: 'conversationSearch/conversationSearch',
|
||||||
|
messages: 'conversationSearch/messageSearch',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
|
||||||
|
const tab = selectedTab.value;
|
||||||
|
pages.value[tab] += 1;
|
||||||
|
store.dispatch(SEARCH_ACTIONS[tab], {
|
||||||
|
q: query.value,
|
||||||
|
page: pages.value[tab],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.dispatch('conversationSearch/clearSearchResults');
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
query.value = '';
|
||||||
|
store.dispatch('conversationSearch/clearSearchResults');
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col w-full bg-n-background">
|
<div class="flex flex-col w-full h-full bg-n-background">
|
||||||
<div class="flex p-4">
|
<div class="flex w-full p-4">
|
||||||
<ButtonV4
|
<NextButton
|
||||||
:label="$t('GENERAL_SETTINGS.BACK')"
|
:label="t('GENERAL_SETTINGS.BACK')"
|
||||||
icon="i-lucide-chevron-left"
|
icon="i-lucide-chevron-left"
|
||||||
faded
|
faded
|
||||||
primary
|
primary
|
||||||
@@ -214,73 +247,119 @@ export default {
|
|||||||
@click="onBack"
|
@click="onBack"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<section
|
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
|
||||||
class="flex my-0 p-4 relative mx-auto max-w-[45rem] min-h-[20rem] flex-col w-full h-full bg-n-background"
|
<div class="w-full max-w-4xl mx-auto">
|
||||||
>
|
<div class="flex flex-col w-full px-4">
|
||||||
<header>
|
<SearchHeader @search="onSearch" />
|
||||||
<SearchHeader @search="onSearch" />
|
<SearchTabs
|
||||||
<SearchTabs
|
v-if="query"
|
||||||
v-if="query"
|
:tabs="tabs"
|
||||||
:tabs="tabs"
|
:selected-tab="activeTabIndex"
|
||||||
:selected-tab="activeTabIndex"
|
@tab-change="tab => (selectedTab = tab)"
|
||||||
@tab-change="tab => (selectedTab = tab)"
|
/>
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
<div class="flex-grow h-full px-2 py-0 overflow-y-auto">
|
|
||||||
<div v-if="showResultsSection">
|
|
||||||
<Policy :permissions="[...rolePermissions, contactPermissions]">
|
|
||||||
<SearchResultContactsList
|
|
||||||
v-if="filterContacts"
|
|
||||||
:is-fetching="uiFlags.contact.isFetching"
|
|
||||||
:contacts="contacts"
|
|
||||||
:query="query"
|
|
||||||
:show-title="isSelectedTabAll"
|
|
||||||
/>
|
|
||||||
</Policy>
|
|
||||||
|
|
||||||
<Policy
|
|
||||||
:permissions="[...rolePermissions, ...conversationPermissions]"
|
|
||||||
>
|
|
||||||
<SearchResultMessagesList
|
|
||||||
v-if="filterMessages"
|
|
||||||
:is-fetching="uiFlags.message.isFetching"
|
|
||||||
:messages="messages"
|
|
||||||
:query="query"
|
|
||||||
:show-title="isSelectedTabAll"
|
|
||||||
/>
|
|
||||||
</Policy>
|
|
||||||
|
|
||||||
<Policy
|
|
||||||
:permissions="[...rolePermissions, ...conversationPermissions]"
|
|
||||||
>
|
|
||||||
<SearchResultConversationsList
|
|
||||||
v-if="filterConversations"
|
|
||||||
:is-fetching="uiFlags.conversation.isFetching"
|
|
||||||
:conversations="conversations"
|
|
||||||
:query="query"
|
|
||||||
:show-title="isSelectedTabAll"
|
|
||||||
/>
|
|
||||||
</Policy>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
</div>
|
||||||
v-else-if="showEmptySearchResults"
|
<div class="flex-grow w-full h-full overflow-y-auto">
|
||||||
class="flex flex-col items-center justify-center px-4 py-6 mt-8 rounded-md"
|
<div class="w-full max-w-4xl mx-auto px-4 pb-6">
|
||||||
>
|
<div v-if="showResultsSection">
|
||||||
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
|
<Policy
|
||||||
<p class="m-2 text-center text-n-slate-11">
|
:permissions="[...ROLES, CONTACT_PERMISSIONS]"
|
||||||
{{ $t('SEARCH.EMPTY_STATE_FULL', { query }) }}
|
class="flex flex-col justify-center"
|
||||||
</p>
|
>
|
||||||
</div>
|
<SearchResultContactsList
|
||||||
<div
|
v-if="filterContacts"
|
||||||
v-else
|
:is-fetching="uiFlags.contact.isFetching"
|
||||||
class="flex flex-col items-center justify-center px-4 py-6 mt-8 text-center rounded-md"
|
:contacts="contacts"
|
||||||
>
|
:query="query"
|
||||||
<p class="text-center margin-bottom-0">
|
:show-title="isSelectedTabAll"
|
||||||
<fluent-icon icon="search" size="24px" class="text-n-slate-11" />
|
/>
|
||||||
</p>
|
<NextButton
|
||||||
<p class="m-2 text-center text-n-slate-11">
|
v-if="showViewMore.contacts"
|
||||||
{{ $t('SEARCH.EMPTY_STATE_DEFAULT') }}
|
:label="t(`SEARCH.VIEW_MORE`)"
|
||||||
</p>
|
icon="i-lucide-eye"
|
||||||
|
slate
|
||||||
|
sm
|
||||||
|
outline
|
||||||
|
@click="selectedTab = 'contacts'"
|
||||||
|
/>
|
||||||
|
</Policy>
|
||||||
|
|
||||||
|
<Policy
|
||||||
|
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
|
||||||
|
class="flex flex-col justify-center"
|
||||||
|
>
|
||||||
|
<SearchResultMessagesList
|
||||||
|
v-if="filterMessages"
|
||||||
|
:is-fetching="uiFlags.message.isFetching"
|
||||||
|
:messages="messages"
|
||||||
|
:query="query"
|
||||||
|
:show-title="isSelectedTabAll"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
v-if="showViewMore.messages"
|
||||||
|
:label="t(`SEARCH.VIEW_MORE`)"
|
||||||
|
icon="i-lucide-eye"
|
||||||
|
slate
|
||||||
|
sm
|
||||||
|
outline
|
||||||
|
@click="selectedTab = 'messages'"
|
||||||
|
/>
|
||||||
|
</Policy>
|
||||||
|
|
||||||
|
<Policy
|
||||||
|
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
|
||||||
|
class="flex flex-col justify-center"
|
||||||
|
>
|
||||||
|
<SearchResultConversationsList
|
||||||
|
v-if="filterConversations"
|
||||||
|
:is-fetching="uiFlags.conversation.isFetching"
|
||||||
|
:conversations="conversations"
|
||||||
|
:query="query"
|
||||||
|
:show-title="isSelectedTabAll"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
v-if="showViewMore.conversations"
|
||||||
|
:label="t(`SEARCH.VIEW_MORE`)"
|
||||||
|
icon="i-lucide-eye"
|
||||||
|
slate
|
||||||
|
sm
|
||||||
|
outline
|
||||||
|
@click="selectedTab = 'conversations'"
|
||||||
|
/>
|
||||||
|
</Policy>
|
||||||
|
|
||||||
|
<div v-if="showLoadMore" class="flex justify-center mt-4 mb-6">
|
||||||
|
<NextButton
|
||||||
|
v-if="!isSelectedTabAll"
|
||||||
|
:label="t(`SEARCH.LOAD_MORE`)"
|
||||||
|
icon="i-lucide-cloud-download"
|
||||||
|
slate
|
||||||
|
sm
|
||||||
|
faded
|
||||||
|
@click="loadMore"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="showEmptySearchResults"
|
||||||
|
class="flex flex-col items-center justify-center px-4 py-6 mt-8 rounded-md"
|
||||||
|
>
|
||||||
|
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
|
||||||
|
<p class="m-2 text-center text-n-slate-11">
|
||||||
|
{{ t('SEARCH.EMPTY_STATE_FULL', { query }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!query"
|
||||||
|
class="flex flex-col items-center justify-center px-4 py-6 mt-8 text-center rounded-md"
|
||||||
|
>
|
||||||
|
<p class="text-center margin-bottom-0">
|
||||||
|
<fluent-icon icon="search" size="24px" class="text-n-slate-11" />
|
||||||
|
</p>
|
||||||
|
<p class="m-2 text-center text-n-slate-11">
|
||||||
|
{{ t('SEARCH.EMPTY_STATE_DEFAULT') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,214 +1,17 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import { mapGetters } from 'vuex';
|
|
||||||
import AgentTable from './components/overview/AgentTable.vue';
|
|
||||||
import MetricCard from './components/overview/MetricCard.vue';
|
|
||||||
import { OVERVIEW_METRICS } from './constants';
|
|
||||||
import ReportHeatmap from './components/Heatmap.vue';
|
|
||||||
|
|
||||||
import endOfDay from 'date-fns/endOfDay';
|
|
||||||
import getUnixTime from 'date-fns/getUnixTime';
|
|
||||||
import startOfDay from 'date-fns/startOfDay';
|
|
||||||
import subDays from 'date-fns/subDays';
|
|
||||||
import ReportHeader from './components/ReportHeader.vue';
|
import ReportHeader from './components/ReportHeader.vue';
|
||||||
export const FETCH_INTERVAL = 60000;
|
import HeatmapContainer from './components/HeatmapContainer.vue';
|
||||||
|
import AgentLiveReportContainer from './components/AgentLiveReportContainer.vue';
|
||||||
export default {
|
import TeamLiveReportContainer from './components/TeamLiveReportContainer.vue';
|
||||||
name: 'LiveReports',
|
import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vue';
|
||||||
components: {
|
|
||||||
ReportHeader,
|
|
||||||
AgentTable,
|
|
||||||
MetricCard,
|
|
||||||
ReportHeatmap,
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
// always start with 0, this is to manage the pagination in tanstack table
|
|
||||||
// when we send the data, we do a +1 to this value
|
|
||||||
pageIndex: 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
|
||||||
agentStatus: 'agents/getAgentStatus',
|
|
||||||
agents: 'agents/getAgents',
|
|
||||||
accountConversationMetric: 'getAccountConversationMetric',
|
|
||||||
agentConversationMetric: 'getAgentConversationMetric',
|
|
||||||
accountConversationHeatmap: 'getAccountConversationHeatmapData',
|
|
||||||
uiFlags: 'getOverviewUIFlags',
|
|
||||||
}),
|
|
||||||
agentStatusMetrics() {
|
|
||||||
let metric = {};
|
|
||||||
Object.keys(this.agentStatus).forEach(key => {
|
|
||||||
const metricName = this.$t(
|
|
||||||
`OVERVIEW_REPORTS.AGENT_STATUS.${OVERVIEW_METRICS[key]}`
|
|
||||||
);
|
|
||||||
metric[metricName] = this.agentStatus[key];
|
|
||||||
});
|
|
||||||
return metric;
|
|
||||||
},
|
|
||||||
conversationMetrics() {
|
|
||||||
let metric = {};
|
|
||||||
Object.keys(this.accountConversationMetric).forEach(key => {
|
|
||||||
const metricName = this.$t(
|
|
||||||
`OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.${OVERVIEW_METRICS[key]}`
|
|
||||||
);
|
|
||||||
metric[metricName] = this.accountConversationMetric[key];
|
|
||||||
});
|
|
||||||
return metric;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$store.dispatch('agents/get');
|
|
||||||
this.initalizeReport();
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
if (this.timeoutId) {
|
|
||||||
clearTimeout(this.timeoutId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
initalizeReport() {
|
|
||||||
this.fetchAllData();
|
|
||||||
this.scheduleReportRefresh();
|
|
||||||
},
|
|
||||||
scheduleReportRefresh() {
|
|
||||||
this.timeoutId = setTimeout(async () => {
|
|
||||||
await this.fetchAllData();
|
|
||||||
this.scheduleReportRefresh();
|
|
||||||
}, FETCH_INTERVAL);
|
|
||||||
},
|
|
||||||
fetchAllData() {
|
|
||||||
this.fetchAccountConversationMetric();
|
|
||||||
this.fetchAgentConversationMetric();
|
|
||||||
this.fetchHeatmapData();
|
|
||||||
},
|
|
||||||
downloadHeatmapData() {
|
|
||||||
let to = endOfDay(new Date());
|
|
||||||
|
|
||||||
this.$store.dispatch('downloadAccountConversationHeatmap', {
|
|
||||||
to: getUnixTime(to),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
fetchHeatmapData() {
|
|
||||||
if (this.uiFlags.isFetchingAccountConversationsHeatmap) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// the data for the last 6 days won't ever change,
|
|
||||||
// so there's no need to fetch it again
|
|
||||||
// but we can write some logic to check if the data is already there
|
|
||||||
// if it is there, we can refetch data only for today all over again
|
|
||||||
// and reconcile it with the rest of the data
|
|
||||||
// this will reduce the load on the server doing number crunching
|
|
||||||
let to = endOfDay(new Date());
|
|
||||||
let from = startOfDay(subDays(to, 6));
|
|
||||||
|
|
||||||
if (this.accountConversationHeatmap.length) {
|
|
||||||
to = endOfDay(new Date());
|
|
||||||
from = startOfDay(to);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$store.dispatch('fetchAccountConversationHeatmap', {
|
|
||||||
metric: 'conversations_count',
|
|
||||||
from: getUnixTime(from),
|
|
||||||
to: getUnixTime(to),
|
|
||||||
groupBy: 'hour',
|
|
||||||
businessHours: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
fetchAccountConversationMetric() {
|
|
||||||
this.$store.dispatch('fetchAccountConversationMetric', {
|
|
||||||
type: 'account',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
fetchAgentConversationMetric() {
|
|
||||||
this.$store.dispatch('fetchAgentConversationMetric', {
|
|
||||||
type: 'agent',
|
|
||||||
page: this.pageIndex + 1,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onPageNumberChange(pageIndex) {
|
|
||||||
this.pageIndex = pageIndex;
|
|
||||||
this.fetchAgentConversationMetric();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
|
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
|
||||||
<div class="flex flex-col gap-4 pb-6">
|
<div class="flex flex-col gap-4 pb-6">
|
||||||
<div class="flex flex-col items-center md:flex-row gap-4">
|
<StatsLiveReportsContainer />
|
||||||
<div
|
<HeatmapContainer />
|
||||||
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
|
<AgentLiveReportContainer />
|
||||||
>
|
<TeamLiveReportContainer />
|
||||||
<MetricCard
|
|
||||||
:header="$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.HEADER')"
|
|
||||||
:is-loading="uiFlags.isFetchingAccountConversationMetric"
|
|
||||||
:loading-message="
|
|
||||||
$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.LOADING_MESSAGE')
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(metric, name, index) in conversationMetrics"
|
|
||||||
:key="index"
|
|
||||||
class="flex-1 min-w-0 pb-2"
|
|
||||||
>
|
|
||||||
<h3 class="text-base text-n-slate-11">
|
|
||||||
{{ name }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
|
|
||||||
{{ metric }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</MetricCard>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%]">
|
|
||||||
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
|
|
||||||
<div
|
|
||||||
v-for="(metric, name, index) in agentStatusMetrics"
|
|
||||||
:key="index"
|
|
||||||
class="flex-1 min-w-0 pb-2"
|
|
||||||
>
|
|
||||||
<h3 class="text-base text-n-slate-11">
|
|
||||||
{{ name }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
|
|
||||||
{{ metric }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</MetricCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row flex-wrap max-w-full">
|
|
||||||
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
|
|
||||||
<template #control>
|
|
||||||
<woot-button
|
|
||||||
icon="arrow-download"
|
|
||||||
size="small"
|
|
||||||
variant="smooth"
|
|
||||||
color-scheme="secondary"
|
|
||||||
@click="downloadHeatmapData"
|
|
||||||
>
|
|
||||||
{{ $t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT') }}
|
|
||||||
</woot-button>
|
|
||||||
</template>
|
|
||||||
<ReportHeatmap
|
|
||||||
:heat-data="accountConversationHeatmap"
|
|
||||||
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
|
|
||||||
/>
|
|
||||||
</MetricCard>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row flex-wrap max-w-full">
|
|
||||||
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
|
|
||||||
<AgentTable
|
|
||||||
:agents="agents"
|
|
||||||
:agent-metrics="agentConversationMetric"
|
|
||||||
:page-index="pageIndex"
|
|
||||||
:is-loading="uiFlags.isFetchingAgentConversationMetric"
|
|
||||||
@page-change="onPageNumberChange"
|
|
||||||
/>
|
|
||||||
</MetricCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
|
||||||
|
import AgentTable from './overview/AgentTable.vue';
|
||||||
|
import MetricCard from './overview/MetricCard.vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||||
|
const agentConversationMetric = useMapGetter('getAgentConversationMetric');
|
||||||
|
const agents = useMapGetter('agents/getAgents');
|
||||||
|
|
||||||
|
const fetchData = () => store.dispatch('fetchAgentConversationMetric');
|
||||||
|
|
||||||
|
const { startRefetching } = useLiveRefresh(fetchData);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.dispatch('agents/get');
|
||||||
|
fetchData();
|
||||||
|
startRefetching();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row flex-wrap max-w-full">
|
||||||
|
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
|
||||||
|
<AgentTable
|
||||||
|
:agents="agents"
|
||||||
|
:agent-metrics="agentConversationMetric"
|
||||||
|
:is-loading="uiFlags.isFetchingAgentConversationMetric"
|
||||||
|
/>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -10,10 +10,14 @@ import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
heatData: {
|
heatmapData: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
numberOfRows: {
|
||||||
|
type: Number,
|
||||||
|
default: 7,
|
||||||
|
},
|
||||||
isLoading: {
|
isLoading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -21,11 +25,11 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const processedData = computed(() => {
|
const processedData = computed(() => {
|
||||||
return groupHeatmapByDay(props.heatData);
|
return groupHeatmapByDay(props.heatmapData);
|
||||||
});
|
});
|
||||||
|
|
||||||
const quantileRange = computed(() => {
|
const quantileRange = computed(() => {
|
||||||
const flattendedData = props.heatData.map(data => data.value);
|
const flattendedData = props.heatmapData.map(data => data.value);
|
||||||
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,14 +99,14 @@ function getHeatmapLevelClass(value) {
|
|||||||
<template v-if="isLoading">
|
<template v-if="isLoading">
|
||||||
<div class="grid gap-[5px] flex-shrink-0">
|
<div class="grid gap-[5px] flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
v-for="ii in 7"
|
v-for="ii in numberOfRows"
|
||||||
:key="ii"
|
:key="ii"
|
||||||
class="w-full rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse h-8 min-w-[70px]"
|
class="w-full rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse h-8 min-w-[70px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-[5px] w-full min-w-[700px]">
|
<div class="grid gap-[5px] w-full min-w-[700px]">
|
||||||
<div
|
<div
|
||||||
v-for="ii in 7"
|
v-for="ii in numberOfRows"
|
||||||
:key="ii"
|
:key="ii"
|
||||||
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||||
>
|
>
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import MetricCard from './overview/MetricCard.vue';
|
||||||
|
import ReportHeatmap from './Heatmap.vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||||
|
import endOfDay from 'date-fns/endOfDay';
|
||||||
|
import getUnixTime from 'date-fns/getUnixTime';
|
||||||
|
import startOfDay from 'date-fns/startOfDay';
|
||||||
|
import subDays from 'date-fns/subDays';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||||
|
const accountConversationHeatmap = useMapGetter(
|
||||||
|
'getAccountConversationHeatmapData'
|
||||||
|
);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||||
|
value: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
|
||||||
|
value: 29,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedDays = ref(6);
|
||||||
|
|
||||||
|
const selectedDayFilter = computed(() =>
|
||||||
|
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadHeatmapData = () => {
|
||||||
|
const to = endOfDay(new Date());
|
||||||
|
store.dispatch('downloadAccountConversationHeatmap', {
|
||||||
|
daysBefore: selectedDays.value,
|
||||||
|
to: getUnixTime(to),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const [showDropdown, toggleDropdown] = useToggle();
|
||||||
|
const fetchHeatmapData = () => {
|
||||||
|
if (uiFlags.value.isFetchingAccountConversationsHeatmap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let to = endOfDay(new Date());
|
||||||
|
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
||||||
|
|
||||||
|
store.dispatch('fetchAccountConversationHeatmap', {
|
||||||
|
metric: 'conversations_count',
|
||||||
|
from: getUnixTime(from),
|
||||||
|
to: getUnixTime(to),
|
||||||
|
groupBy: 'hour',
|
||||||
|
businessHours: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = ({ value }) => {
|
||||||
|
toggleDropdown(false);
|
||||||
|
selectedDays.value = value;
|
||||||
|
fetchHeatmapData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchHeatmapData();
|
||||||
|
startRefetching();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row flex-wrap max-w-full">
|
||||||
|
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
|
||||||
|
<template #control>
|
||||||
|
<div
|
||||||
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
class="relative flex items-center group"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="selectedDayFilter.label"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="toggleDropdown()"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showDropdown"
|
||||||
|
:menu-items="menuItems"
|
||||||
|
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||||
|
@action="handleAction($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="downloadHeatmapData"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<ReportHeatmap
|
||||||
|
:heatmap-data="accountConversationHeatmap"
|
||||||
|
:number-of-rows="selectedDays + 1"
|
||||||
|
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
|
||||||
|
/>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { OVERVIEW_METRICS } from '../constants';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
|
||||||
|
import MetricCard from './overview/MetricCard.vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||||
|
const agentStatus = useMapGetter('agents/getAgentStatus');
|
||||||
|
const accountConversationMetric = useMapGetter('getAccountConversationMetric');
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const accounti18nKey = 'OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS';
|
||||||
|
const teams = useMapGetter('teams/getTeams');
|
||||||
|
|
||||||
|
const teamMenuList = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.ALL_TEAMS'), value: null },
|
||||||
|
...teams.value.map(team => ({ label: team.name, value: team.id })),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentStatusMetrics = computed(() => {
|
||||||
|
let metric = {};
|
||||||
|
Object.keys(agentStatus.value).forEach(key => {
|
||||||
|
const metricName = t(
|
||||||
|
`OVERVIEW_REPORTS.AGENT_STATUS.${OVERVIEW_METRICS[key]}`
|
||||||
|
);
|
||||||
|
metric[metricName] = agentStatus.value[key];
|
||||||
|
});
|
||||||
|
return metric;
|
||||||
|
});
|
||||||
|
const conversationMetrics = computed(() => {
|
||||||
|
let metric = {};
|
||||||
|
Object.keys(accountConversationMetric.value).forEach(key => {
|
||||||
|
const metricName = t(`${accounti18nKey}.${OVERVIEW_METRICS[key]}`);
|
||||||
|
metric[metricName] = accountConversationMetric.value[key];
|
||||||
|
});
|
||||||
|
return metric;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedTeam = ref(null);
|
||||||
|
const selectedTeamLabel = computed(() => {
|
||||||
|
const team =
|
||||||
|
teamMenuList.value.find(
|
||||||
|
menuItem => menuItem.value === selectedTeam.value
|
||||||
|
) || {};
|
||||||
|
return team.label;
|
||||||
|
});
|
||||||
|
const fetchData = () => {
|
||||||
|
const params = {};
|
||||||
|
if (selectedTeam.value) {
|
||||||
|
params.team_id = selectedTeam.value;
|
||||||
|
}
|
||||||
|
store.dispatch('fetchAccountConversationMetric', params);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { startRefetching } = useLiveRefresh(fetchData);
|
||||||
|
const [showDropdown, toggleDropdown] = useToggle();
|
||||||
|
|
||||||
|
const handleAction = ({ value }) => {
|
||||||
|
toggleDropdown(false);
|
||||||
|
selectedTeam.value = value;
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
startRefetching();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center md:flex-row gap-4">
|
||||||
|
<div
|
||||||
|
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
|
||||||
|
>
|
||||||
|
<MetricCard
|
||||||
|
:header="t(`${accounti18nKey}.HEADER`)"
|
||||||
|
:is-loading="uiFlags.isFetchingAccountConversationMetric"
|
||||||
|
:loading-message="t(`${accounti18nKey}.LOADING_MESSAGE`)"
|
||||||
|
>
|
||||||
|
<template v-if="teams.length" #control>
|
||||||
|
<div
|
||||||
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
class="relative flex items-center group z-50"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="selectedTeamLabel"
|
||||||
|
class="capitalize rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="toggleDropdown()"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showDropdown"
|
||||||
|
:menu-items="teamMenuList"
|
||||||
|
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||||
|
label-class="capitalize"
|
||||||
|
@action="handleAction($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-for="(metric, name, index) in conversationMetrics"
|
||||||
|
:key="index"
|
||||||
|
class="flex-1 min-w-0 pb-2"
|
||||||
|
>
|
||||||
|
<h3 class="text-base text-n-slate-11">
|
||||||
|
{{ name }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
|
||||||
|
{{ metric }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%]">
|
||||||
|
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
|
||||||
|
<div
|
||||||
|
v-for="(metric, name, index) in agentStatusMetrics"
|
||||||
|
:key="index"
|
||||||
|
class="flex-1 min-w-0 pb-2"
|
||||||
|
>
|
||||||
|
<h3 class="text-base text-n-slate-11">
|
||||||
|
{{ name }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
|
||||||
|
{{ metric }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
|
||||||
|
import MetricCard from './overview/MetricCard.vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||||
|
import TeamTable from './overview/TeamTable.vue';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||||
|
const teamConversationMetric = useMapGetter('getTeamConversationMetric');
|
||||||
|
const teams = useMapGetter('teams/getTeams');
|
||||||
|
|
||||||
|
const fetchData = () => store.dispatch('fetchTeamConversationMetric');
|
||||||
|
|
||||||
|
const { startRefetching } = useLiveRefresh(fetchData);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.dispatch('teams/get');
|
||||||
|
fetchData();
|
||||||
|
startRefetching();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row flex-wrap max-w-full">
|
||||||
|
<MetricCard :header="$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.HEADER')">
|
||||||
|
<TeamTable
|
||||||
|
:teams="teams"
|
||||||
|
:team-metrics="teamConversationMetric"
|
||||||
|
:is-loading="uiFlags.isFetchingTeamConversationMetric"
|
||||||
|
/>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+38
-101
@@ -4,6 +4,7 @@ import {
|
|||||||
useVueTable,
|
useVueTable,
|
||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
} from '@tanstack/vue-table';
|
} from '@tanstack/vue-table';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ import Table from 'dashboard/components/table/Table.vue';
|
|||||||
import Pagination from 'dashboard/components/table/Pagination.vue';
|
import Pagination from 'dashboard/components/table/Pagination.vue';
|
||||||
import AgentCell from './AgentCell.vue';
|
import AgentCell from './AgentCell.vue';
|
||||||
|
|
||||||
const { agents, agentMetrics, pageIndex } = defineProps({
|
const { agents, agentMetrics } = defineProps({
|
||||||
agents: {
|
agents: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
@@ -26,42 +27,45 @@ const { agents, agentMetrics, pageIndex } = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
pageIndex: {
|
|
||||||
type: Number,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['pageChange']);
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
function getAgentInformation(id) {
|
const getAgentMetrics = id =>
|
||||||
return agents?.find(agent => agent.id === Number(id));
|
agentMetrics.find(metrics => metrics.assignee_id === Number(id)) || {};
|
||||||
}
|
|
||||||
|
|
||||||
const totalCount = computed(() => agents.length);
|
const tableData = computed(() =>
|
||||||
|
agents
|
||||||
const tableData = computed(() => {
|
|
||||||
return agentMetrics
|
|
||||||
.filter(agentMetric => getAgentInformation(agentMetric.id))
|
|
||||||
.map(agent => {
|
.map(agent => {
|
||||||
const agentInformation = getAgentInformation(agent.id);
|
const metric = getAgentMetrics(agent.id);
|
||||||
return {
|
return {
|
||||||
agent: agentInformation.name || agentInformation.available_name,
|
agent: agent.available_name || agent.name,
|
||||||
email: agentInformation.email,
|
email: agent.email,
|
||||||
thumbnail: agentInformation.thumbnail,
|
thumbnail: agent.thumbnail,
|
||||||
open: agent.metric.open ?? 0,
|
open: metric.open || 0,
|
||||||
unattended: agent.metric.unattended ?? 0,
|
unattended: metric.unattended || 0,
|
||||||
status: agentInformation.availability_status,
|
status: agent.availability_status,
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
});
|
.sort((a, b) => {
|
||||||
|
// First sort by open tickets (descending)
|
||||||
|
const openDiff = b.open - a.open;
|
||||||
|
// If open tickets are equal, sort by name (ascending)
|
||||||
|
if (openDiff === 0) {
|
||||||
|
return a.agent.localeCompare(b.agent);
|
||||||
|
}
|
||||||
|
return openDiff;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const defaulSpanRender = cellProps =>
|
const defaulSpanRender = cellProps =>
|
||||||
h(
|
h(
|
||||||
'span',
|
'span',
|
||||||
|
|
||||||
{
|
{
|
||||||
class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
|
class: cellProps.getValue()
|
||||||
|
? 'capitalize text-n-slate-12'
|
||||||
|
: 'capitalize text-n-slate-11',
|
||||||
},
|
},
|
||||||
cellProps.getValue() ? cellProps.getValue() : '---'
|
cellProps.getValue() ? cellProps.getValue() : '---'
|
||||||
);
|
);
|
||||||
@@ -86,100 +90,33 @@ const columns = [
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const paginationParams = computed(() => {
|
|
||||||
return {
|
|
||||||
pageIndex: pageIndex,
|
|
||||||
pageSize: 25,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const table = useVueTable({
|
const table = useVueTable({
|
||||||
get data() {
|
get data() {
|
||||||
return tableData.value;
|
return tableData.value;
|
||||||
},
|
},
|
||||||
columns,
|
columns,
|
||||||
manualPagination: true,
|
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
get rowCount() {
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
return totalCount.value;
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
get pagination() {
|
|
||||||
return paginationParams.value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onPaginationChange: updater => {
|
|
||||||
const newPagintaion = updater(paginationParams.value);
|
|
||||||
emit('pageChange', newPagintaion.pageIndex);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="agent-table-container">
|
<div class="flex flex-col flex-1">
|
||||||
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
|
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
|
||||||
<Pagination class="mt-2" :table="table" />
|
<Pagination class="mt-2" :table="table" />
|
||||||
<div v-if="isLoading" class="agents-loader">
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="items-center flex text-base justify-center p-8"
|
||||||
|
>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<span>{{
|
<span>
|
||||||
$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE')
|
{{ $t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE') }}
|
||||||
}}</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else-if="!isLoading && !agentMetrics.length"
|
v-else-if="!isLoading && !agents.length"
|
||||||
:title="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.NO_AGENTS')"
|
:title="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.NO_AGENTS')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.agent-table-container {
|
|
||||||
@apply flex flex-col flex-1;
|
|
||||||
|
|
||||||
.ve-table {
|
|
||||||
&::v-deep {
|
|
||||||
th.ve-table-header-th {
|
|
||||||
@apply text-sm rounded-xl;
|
|
||||||
padding: var(--space-small) var(--space-two) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
td.ve-table-body-td {
|
|
||||||
padding: var(--space-one) var(--space-two) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&::v-deep .ve-pagination {
|
|
||||||
@apply bg-transparent dark:bg-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::v-deep .ve-pagination-select {
|
|
||||||
@apply hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-user-block {
|
|
||||||
@apply items-center flex text-left;
|
|
||||||
|
|
||||||
.user-block {
|
|
||||||
@apply items-start flex flex-col min-w-0 my-0 mx-2;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
@apply text-sm m-0 leading-[1.2] text-slate-800 dark:text-slate-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-title {
|
|
||||||
@apply text-xs text-slate-600 dark:text-slate-200;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-pagination {
|
|
||||||
@apply mt-4 text-right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.agents-loader {
|
|
||||||
@apply items-center flex text-base justify-center p-8;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
+15
-23
@@ -1,31 +1,25 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import Spinner from 'shared/components/Spinner.vue';
|
import Spinner from 'shared/components/Spinner.vue';
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
name: 'MetricCard',
|
header: {
|
||||||
components: {
|
type: String,
|
||||||
Spinner,
|
default: '',
|
||||||
},
|
},
|
||||||
props: {
|
isLoading: {
|
||||||
header: {
|
type: Boolean,
|
||||||
type: String,
|
default: false,
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
isLoading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
loadingMessage: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
loadingMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col m-0.5 px-6 py-5 overflow-hidden rounded-xl flex-grow text-n-slate-12 shadow outline-1 outline outline-n-container bg-n-solid-2 min-h-[10rem]"
|
class="flex flex-col m-0.5 px-6 py-5 rounded-xl flex-grow text-n-slate-12 shadow outline-1 outline outline-n-container bg-n-solid-2 min-h-[10rem]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="card-header grid w-full mb-6 grid-cols-[repeat(auto-fit,minmax(max-content,50%))] gap-y-2"
|
class="card-header grid w-full mb-6 grid-cols-[repeat(auto-fit,minmax(max-content,50%))] gap-y-2"
|
||||||
@@ -46,9 +40,7 @@ export default {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex flex-row items-center justify-end gap-2">
|
||||||
class="transition-opacity duration-200 ease-in-out opacity-20 hover:opacity-100 flex flex-row items-center justify-end gap-2"
|
|
||||||
>
|
|
||||||
<slot name="control" />
|
<slot name="control" />
|
||||||
</div>
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, h } from 'vue';
|
||||||
|
import {
|
||||||
|
useVueTable,
|
||||||
|
createColumnHelper,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
} from '@tanstack/vue-table';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Spinner from 'shared/components/Spinner.vue';
|
||||||
|
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||||
|
import Table from 'dashboard/components/table/Table.vue';
|
||||||
|
import Pagination from 'dashboard/components/table/Pagination.vue';
|
||||||
|
|
||||||
|
const { teams, teamMetrics } = defineProps({
|
||||||
|
teams: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
teamMetrics: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const getTeamMetrics = id =>
|
||||||
|
teamMetrics.find(metrics => metrics.team_id === Number(id)) || {};
|
||||||
|
|
||||||
|
const tableData = computed(() =>
|
||||||
|
teams
|
||||||
|
.map(team => {
|
||||||
|
const metric = getTeamMetrics(team.id);
|
||||||
|
return {
|
||||||
|
agent: team.name,
|
||||||
|
open: metric.open || 0,
|
||||||
|
unattended: metric.unattended || 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// First sort by open tickets (descending)
|
||||||
|
const openDiff = b.open - a.open;
|
||||||
|
// If open tickets are equal, sort by name (ascending)
|
||||||
|
if (openDiff === 0) {
|
||||||
|
return a.agent.localeCompare(b.agent);
|
||||||
|
}
|
||||||
|
return openDiff;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaulSpanRender = cellProps =>
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
class: cellProps.getValue()
|
||||||
|
? 'capitalize text-n-slate-12'
|
||||||
|
: 'capitalize text-n-slate-11',
|
||||||
|
},
|
||||||
|
cellProps.getValue() ? cellProps.getValue() : '---'
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper();
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor('agent', {
|
||||||
|
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.TEAM'),
|
||||||
|
cell: defaulSpanRender,
|
||||||
|
size: 250,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('open', {
|
||||||
|
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.OPEN'),
|
||||||
|
cell: defaulSpanRender,
|
||||||
|
size: 100,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('unattended', {
|
||||||
|
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.UNATTENDED'),
|
||||||
|
cell: defaulSpanRender,
|
||||||
|
size: 100,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useVueTable({
|
||||||
|
get data() {
|
||||||
|
return tableData.value;
|
||||||
|
},
|
||||||
|
columns,
|
||||||
|
enableSorting: false,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col flex-1">
|
||||||
|
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
|
||||||
|
<Pagination class="mt-2" :table="table" />
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="items-center flex text-base justify-center p-8"
|
||||||
|
>
|
||||||
|
<Spinner />
|
||||||
|
<span>
|
||||||
|
{{ $t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.LOADING_MESSAGE') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="!isLoading && !teams.length"
|
||||||
|
:title="$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.NO_TEAMS')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -75,11 +75,10 @@ export const actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async contactSearch({ commit }, { q }) {
|
async contactSearch({ commit }, { q, page = 1 }) {
|
||||||
commit(types.CONTACT_SEARCH_SET, []);
|
|
||||||
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true });
|
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true });
|
||||||
try {
|
try {
|
||||||
const { data } = await SearchAPI.contacts({ q });
|
const { data } = await SearchAPI.contacts({ q, page });
|
||||||
commit(types.CONTACT_SEARCH_SET, data.payload.contacts);
|
commit(types.CONTACT_SEARCH_SET, data.payload.contacts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
@@ -87,11 +86,10 @@ export const actions = {
|
|||||||
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false });
|
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async conversationSearch({ commit }, { q }) {
|
async conversationSearch({ commit }, { q, page = 1 }) {
|
||||||
commit(types.CONVERSATION_SEARCH_SET, []);
|
|
||||||
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true });
|
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true });
|
||||||
try {
|
try {
|
||||||
const { data } = await SearchAPI.conversations({ q });
|
const { data } = await SearchAPI.conversations({ q, page });
|
||||||
commit(types.CONVERSATION_SEARCH_SET, data.payload.conversations);
|
commit(types.CONVERSATION_SEARCH_SET, data.payload.conversations);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
@@ -99,11 +97,10 @@ export const actions = {
|
|||||||
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false });
|
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async messageSearch({ commit }, { q }) {
|
async messageSearch({ commit }, { q, page = 1 }) {
|
||||||
commit(types.MESSAGE_SEARCH_SET, []);
|
|
||||||
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true });
|
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true });
|
||||||
try {
|
try {
|
||||||
const { data } = await SearchAPI.messages({ q });
|
const { data } = await SearchAPI.messages({ q, page });
|
||||||
commit(types.MESSAGE_SEARCH_SET, data.payload.messages);
|
commit(types.MESSAGE_SEARCH_SET, data.payload.messages);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
@@ -112,9 +109,7 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async clearSearchResults({ commit }) {
|
async clearSearchResults({ commit }) {
|
||||||
commit(types.MESSAGE_SEARCH_SET, []);
|
commit(types.CLEAR_SEARCH_RESULTS);
|
||||||
commit(types.CONVERSATION_SEARCH_SET, []);
|
|
||||||
commit(types.CONTACT_SEARCH_SET, []);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,13 +118,13 @@ export const mutations = {
|
|||||||
state.records = records;
|
state.records = records;
|
||||||
},
|
},
|
||||||
[types.CONTACT_SEARCH_SET](state, records) {
|
[types.CONTACT_SEARCH_SET](state, records) {
|
||||||
state.contactRecords = records;
|
state.contactRecords = [...state.contactRecords, ...records];
|
||||||
},
|
},
|
||||||
[types.CONVERSATION_SEARCH_SET](state, records) {
|
[types.CONVERSATION_SEARCH_SET](state, records) {
|
||||||
state.conversationRecords = records;
|
state.conversationRecords = [...state.conversationRecords, ...records];
|
||||||
},
|
},
|
||||||
[types.MESSAGE_SEARCH_SET](state, records) {
|
[types.MESSAGE_SEARCH_SET](state, records) {
|
||||||
state.messageRecords = records;
|
state.messageRecords = [...state.messageRecords, ...records];
|
||||||
},
|
},
|
||||||
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
|
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
|
||||||
state.uiFlags = { ...state.uiFlags, ...uiFlags };
|
state.uiFlags = { ...state.uiFlags, ...uiFlags };
|
||||||
@@ -146,6 +141,11 @@ export const mutations = {
|
|||||||
[types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) {
|
[types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) {
|
||||||
state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags };
|
state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags };
|
||||||
},
|
},
|
||||||
|
[types.CLEAR_SEARCH_RESULTS](state) {
|
||||||
|
state.contactRecords = [];
|
||||||
|
state.conversationRecords = [];
|
||||||
|
state.messageRecords = [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import Report from '../../api/reports';
|
|||||||
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
|
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
|
||||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||||
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||||
import {
|
import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper';
|
||||||
reconcileHeatmapData,
|
import liveReports from '../../api/liveReports';
|
||||||
clampDataBetweenTimeline,
|
|
||||||
} from 'shared/helpers/ReportsDataHelper';
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
fetchingStatus: false,
|
fetchingStatus: false,
|
||||||
@@ -57,10 +55,12 @@ const state = {
|
|||||||
isFetchingAccountConversationMetric: false,
|
isFetchingAccountConversationMetric: false,
|
||||||
isFetchingAccountConversationsHeatmap: false,
|
isFetchingAccountConversationsHeatmap: false,
|
||||||
isFetchingAgentConversationMetric: false,
|
isFetchingAgentConversationMetric: false,
|
||||||
|
isFetchingTeamConversationMetric: false,
|
||||||
},
|
},
|
||||||
accountConversationMetric: {},
|
accountConversationMetric: {},
|
||||||
accountConversationHeatmap: [],
|
accountConversationHeatmap: [],
|
||||||
agentConversationMetric: [],
|
agentConversationMetric: [],
|
||||||
|
teamConversationMetric: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,6 +83,9 @@ const getters = {
|
|||||||
getAgentConversationMetric(_state) {
|
getAgentConversationMetric(_state) {
|
||||||
return _state.overview.agentConversationMetric;
|
return _state.overview.agentConversationMetric;
|
||||||
},
|
},
|
||||||
|
getTeamConversationMetric(_state) {
|
||||||
|
return _state.overview.teamConversationMetric;
|
||||||
|
},
|
||||||
getOverviewUIFlags($state) {
|
getOverviewUIFlags($state) {
|
||||||
return $state.overview.uiFlags;
|
return $state.overview.uiFlags;
|
||||||
},
|
},
|
||||||
@@ -114,11 +117,6 @@ export const actions = {
|
|||||||
let { data } = heatmapData;
|
let { data } = heatmapData;
|
||||||
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
|
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
|
||||||
|
|
||||||
data = reconcileHeatmapData(
|
|
||||||
data,
|
|
||||||
state.overview.accountConversationHeatmap
|
|
||||||
);
|
|
||||||
|
|
||||||
commit(types.default.SET_HEATMAP_DATA, data);
|
commit(types.default.SET_HEATMAP_DATA, data);
|
||||||
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
|
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
|
||||||
});
|
});
|
||||||
@@ -153,9 +151,10 @@ export const actions = {
|
|||||||
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
|
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
fetchAccountConversationMetric({ commit }, reportObj) {
|
fetchAccountConversationMetric({ commit }, params = {}) {
|
||||||
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
|
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
|
||||||
Report.getConversationMetric(reportObj.type)
|
liveReports
|
||||||
|
.getConversationMetric(params)
|
||||||
.then(accountConversationMetric => {
|
.then(accountConversationMetric => {
|
||||||
commit(
|
commit(
|
||||||
types.default.SET_ACCOUNT_CONVERSATION_METRIC,
|
types.default.SET_ACCOUNT_CONVERSATION_METRIC,
|
||||||
@@ -167,9 +166,10 @@ export const actions = {
|
|||||||
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
|
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
fetchAgentConversationMetric({ commit }, reportObj) {
|
fetchAgentConversationMetric({ commit }) {
|
||||||
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true);
|
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true);
|
||||||
Report.getConversationMetric(reportObj.type, reportObj.page)
|
liveReports
|
||||||
|
.getGroupedConversations({ groupBy: 'assignee_id' })
|
||||||
.then(agentConversationMetric => {
|
.then(agentConversationMetric => {
|
||||||
commit(
|
commit(
|
||||||
types.default.SET_AGENT_CONVERSATION_METRIC,
|
types.default.SET_AGENT_CONVERSATION_METRIC,
|
||||||
@@ -181,6 +181,18 @@ export const actions = {
|
|||||||
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false);
|
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
fetchTeamConversationMetric({ commit }) {
|
||||||
|
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, true);
|
||||||
|
liveReports
|
||||||
|
.getGroupedConversations({ groupBy: 'team_id' })
|
||||||
|
.then(teamMetric => {
|
||||||
|
commit(types.default.SET_TEAM_CONVERSATION_METRIC, teamMetric.data);
|
||||||
|
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
|
||||||
|
});
|
||||||
|
},
|
||||||
downloadAgentReports(_, reportObj) {
|
downloadAgentReports(_, reportObj) {
|
||||||
return Report.getAgentReports(reportObj)
|
return Report.getAgentReports(reportObj)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
@@ -234,7 +246,7 @@ export const actions = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
downloadAccountConversationHeatmap(_, reportObj) {
|
downloadAccountConversationHeatmap(_, reportObj) {
|
||||||
Report.getConversationTrafficCSV()
|
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
|
||||||
.then(response => {
|
.then(response => {
|
||||||
downloadCsvFile(
|
downloadCsvFile(
|
||||||
generateFileName({
|
generateFileName({
|
||||||
@@ -286,6 +298,12 @@ const mutations = {
|
|||||||
[types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) {
|
[types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) {
|
||||||
_state.overview.uiFlags.isFetchingAgentConversationMetric = flag;
|
_state.overview.uiFlags.isFetchingAgentConversationMetric = flag;
|
||||||
},
|
},
|
||||||
|
[types.default.SET_TEAM_CONVERSATION_METRIC](_state, metricData) {
|
||||||
|
_state.overview.teamConversationMetric = metricData;
|
||||||
|
},
|
||||||
|
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
|
||||||
|
_state.overview.uiFlags.isFetchingTeamConversationMetric = flag;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { actions } from '../../conversationSearch';
|
import { actions } from '../../conversationSearch';
|
||||||
import types from '../../../mutation-types';
|
import types from '../../../mutation-types';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const commit = vi.fn();
|
const commit = vi.fn();
|
||||||
|
const dispatch = vi.fn();
|
||||||
global.axios = axios;
|
global.axios = axios;
|
||||||
vi.mock('axios');
|
vi.mock('axios');
|
||||||
|
|
||||||
describe('#actions', () => {
|
describe('#actions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
commit.mockClear();
|
||||||
|
dispatch.mockClear();
|
||||||
|
axios.get.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
describe('#get', () => {
|
describe('#get', () => {
|
||||||
it('sends correct actions if no query param is provided', () => {
|
it('sends correct actions if no query param is provided', () => {
|
||||||
actions.get({ commit }, { q: '' });
|
actions.get({ commit }, { q: '' });
|
||||||
@@ -41,4 +49,111 @@ describe('#actions', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#fullSearch', () => {
|
||||||
|
it('should not dispatch any actions if no query provided', async () => {
|
||||||
|
await actions.fullSearch({ commit, dispatch }, { q: '' });
|
||||||
|
expect(dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should dispatch all search actions and set UI flags correctly', async () => {
|
||||||
|
await actions.fullSearch({ commit, dispatch }, { q: 'test' });
|
||||||
|
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[
|
||||||
|
types.FULL_SEARCH_SET_UI_FLAG,
|
||||||
|
{ isFetching: true, isSearchCompleted: false },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
types.FULL_SEARCH_SET_UI_FLAG,
|
||||||
|
{ isFetching: false, isSearchCompleted: true },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(dispatch).toHaveBeenCalledWith('contactSearch', { q: 'test' });
|
||||||
|
expect(dispatch).toHaveBeenCalledWith('conversationSearch', {
|
||||||
|
q: 'test',
|
||||||
|
});
|
||||||
|
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#contactSearch', () => {
|
||||||
|
it('should handle successful contact search', async () => {
|
||||||
|
axios.get.mockResolvedValue({
|
||||||
|
data: { payload: { contacts: [{ id: 1 }] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await actions.contactSearch({ commit }, { q: 'test', page: 1 });
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true }],
|
||||||
|
[types.CONTACT_SEARCH_SET, [{ id: 1 }]],
|
||||||
|
[types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle failed contact search', async () => {
|
||||||
|
axios.get.mockRejectedValue({});
|
||||||
|
await actions.contactSearch({ commit }, { q: 'test' });
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true }],
|
||||||
|
[types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#conversationSearch', () => {
|
||||||
|
it('should handle successful conversation search', async () => {
|
||||||
|
axios.get.mockResolvedValue({
|
||||||
|
data: { payload: { conversations: [{ id: 1 }] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await actions.conversationSearch({ commit }, { q: 'test', page: 1 });
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true }],
|
||||||
|
[types.CONVERSATION_SEARCH_SET, [{ id: 1 }]],
|
||||||
|
[types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle failed conversation search', async () => {
|
||||||
|
axios.get.mockRejectedValue({});
|
||||||
|
await actions.conversationSearch({ commit }, { q: 'test' });
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true }],
|
||||||
|
[types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#messageSearch', () => {
|
||||||
|
it('should handle successful message search', async () => {
|
||||||
|
axios.get.mockResolvedValue({
|
||||||
|
data: { payload: { messages: [{ id: 1 }] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await actions.messageSearch({ commit }, { q: 'test', page: 1 });
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true }],
|
||||||
|
[types.MESSAGE_SEARCH_SET, [{ id: 1 }]],
|
||||||
|
[types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle failed message search', async () => {
|
||||||
|
axios.get.mockRejectedValue({});
|
||||||
|
await actions.messageSearch({ commit }, { q: 'test' });
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true }],
|
||||||
|
[types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#clearSearchResults', () => {
|
||||||
|
it('should commit clear search results mutation', () => {
|
||||||
|
actions.clearSearchResults({ commit });
|
||||||
|
expect(commit).toHaveBeenCalledWith(types.CLEAR_SEARCH_RESULTS);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,10 +10,49 @@ describe('#getters', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getContactRecords', () => {
|
||||||
|
const state = {
|
||||||
|
contactRecords: [{ id: 1, name: 'Contact 1' }],
|
||||||
|
};
|
||||||
|
expect(getters.getContactRecords(state)).toEqual([
|
||||||
|
{ id: 1, name: 'Contact 1' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getConversationRecords', () => {
|
||||||
|
const state = {
|
||||||
|
conversationRecords: [{ id: 1, title: 'Conversation 1' }],
|
||||||
|
};
|
||||||
|
expect(getters.getConversationRecords(state)).toEqual([
|
||||||
|
{ id: 1, title: 'Conversation 1' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getMessageRecords', () => {
|
||||||
|
const state = {
|
||||||
|
messageRecords: [{ id: 1, content: 'Message 1' }],
|
||||||
|
};
|
||||||
|
expect(getters.getMessageRecords(state)).toEqual([
|
||||||
|
{ id: 1, content: 'Message 1' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('getUIFlags', () => {
|
it('getUIFlags', () => {
|
||||||
const state = {
|
const state = {
|
||||||
uiFlags: { isFetching: false },
|
uiFlags: {
|
||||||
|
isFetching: false,
|
||||||
|
isSearchCompleted: true,
|
||||||
|
contact: { isFetching: true },
|
||||||
|
message: { isFetching: false },
|
||||||
|
conversation: { isFetching: false },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
expect(getters.getUIFlags(state)).toEqual({ isFetching: false });
|
expect(getters.getUIFlags(state)).toEqual({
|
||||||
|
isFetching: false,
|
||||||
|
isSearchCompleted: true,
|
||||||
|
contact: { isFetching: true },
|
||||||
|
message: { isFetching: false },
|
||||||
|
conversation: { isFetching: false },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ describe('#mutations', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#SEARCH_CONVERSATIONS_SET', () => {
|
describe('#SEARCH_CONVERSATIONS_SET_UI_FLAG', () => {
|
||||||
it('set uiFlags correctly', () => {
|
it('set uiFlags correctly', () => {
|
||||||
const state = { uiFlags: { isFetching: true } };
|
const state = { uiFlags: { isFetching: true } };
|
||||||
mutations[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, {
|
mutations[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, {
|
||||||
@@ -19,4 +19,99 @@ describe('#mutations', () => {
|
|||||||
expect(state.uiFlags).toEqual({ isFetching: false });
|
expect(state.uiFlags).toEqual({ isFetching: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#CONTACT_SEARCH_SET', () => {
|
||||||
|
it('should append new contact records to existing ones', () => {
|
||||||
|
const state = { contactRecords: [{ id: 1 }] };
|
||||||
|
mutations[types.CONTACT_SEARCH_SET](state, [{ id: 2 }]);
|
||||||
|
expect(state.contactRecords).toEqual([{ id: 1 }, { id: 2 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#CONVERSATION_SEARCH_SET', () => {
|
||||||
|
it('should append new conversation records to existing ones', () => {
|
||||||
|
const state = { conversationRecords: [{ id: 1 }] };
|
||||||
|
mutations[types.CONVERSATION_SEARCH_SET](state, [{ id: 2 }]);
|
||||||
|
expect(state.conversationRecords).toEqual([{ id: 1 }, { id: 2 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#MESSAGE_SEARCH_SET', () => {
|
||||||
|
it('should append new message records to existing ones', () => {
|
||||||
|
const state = { messageRecords: [{ id: 1 }] };
|
||||||
|
mutations[types.MESSAGE_SEARCH_SET](state, [{ id: 2 }]);
|
||||||
|
expect(state.messageRecords).toEqual([{ id: 1 }, { id: 2 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#FULL_SEARCH_SET_UI_FLAG', () => {
|
||||||
|
it('set full search UI flags correctly', () => {
|
||||||
|
const state = {
|
||||||
|
uiFlags: {
|
||||||
|
isFetching: true,
|
||||||
|
isSearchCompleted: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mutations[types.FULL_SEARCH_SET_UI_FLAG](state, {
|
||||||
|
isFetching: false,
|
||||||
|
isSearchCompleted: true,
|
||||||
|
});
|
||||||
|
expect(state.uiFlags).toEqual({
|
||||||
|
isFetching: false,
|
||||||
|
isSearchCompleted: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#CONTACT_SEARCH_SET_UI_FLAG', () => {
|
||||||
|
it('set contact search UI flags correctly', () => {
|
||||||
|
const state = {
|
||||||
|
uiFlags: {
|
||||||
|
contact: { isFetching: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mutations[types.CONTACT_SEARCH_SET_UI_FLAG](state, { isFetching: false });
|
||||||
|
expect(state.uiFlags.contact).toEqual({ isFetching: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#CONVERSATION_SEARCH_SET_UI_FLAG', () => {
|
||||||
|
it('set conversation search UI flags correctly', () => {
|
||||||
|
const state = {
|
||||||
|
uiFlags: {
|
||||||
|
conversation: { isFetching: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mutations[types.CONVERSATION_SEARCH_SET_UI_FLAG](state, {
|
||||||
|
isFetching: false,
|
||||||
|
});
|
||||||
|
expect(state.uiFlags.conversation).toEqual({ isFetching: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#MESSAGE_SEARCH_SET_UI_FLAG', () => {
|
||||||
|
it('set message search UI flags correctly', () => {
|
||||||
|
const state = {
|
||||||
|
uiFlags: {
|
||||||
|
message: { isFetching: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mutations[types.MESSAGE_SEARCH_SET_UI_FLAG](state, { isFetching: false });
|
||||||
|
expect(state.uiFlags.message).toEqual({ isFetching: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#CLEAR_SEARCH_RESULTS', () => {
|
||||||
|
it('should clear all search records', () => {
|
||||||
|
const state = {
|
||||||
|
contactRecords: [{ id: 1 }],
|
||||||
|
conversationRecords: [{ id: 1 }],
|
||||||
|
messageRecords: [{ id: 1 }],
|
||||||
|
};
|
||||||
|
mutations[types.CLEAR_SEARCH_RESULTS](state);
|
||||||
|
expect(state.contactRecords).toEqual([]);
|
||||||
|
expect(state.conversationRecords).toEqual([]);
|
||||||
|
expect(state.messageRecords).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ export default {
|
|||||||
CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET',
|
CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET',
|
||||||
CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG',
|
CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG',
|
||||||
MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET',
|
MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET',
|
||||||
|
CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS',
|
||||||
MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG',
|
MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG',
|
||||||
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
|
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
|
||||||
SET_CONVERSATION_PARTICIPANTS_UI_FLAG:
|
SET_CONVERSATION_PARTICIPANTS_UI_FLAG:
|
||||||
@@ -334,4 +335,8 @@ export default {
|
|||||||
SET_SLA_REPORTS: 'SET_SLA_REPORTS',
|
SET_SLA_REPORTS: 'SET_SLA_REPORTS',
|
||||||
SET_SLA_REPORTS_METRICS: 'SET_SLA_REPORTS_METRICS',
|
SET_SLA_REPORTS_METRICS: 'SET_SLA_REPORTS_METRICS',
|
||||||
SET_SLA_REPORTS_META: 'SET_SLA_REPORTS_META',
|
SET_SLA_REPORTS_META: 'SET_SLA_REPORTS_META',
|
||||||
|
|
||||||
|
SET_TEAM_CONVERSATION_METRIC: 'SET_TEAM_CONVERSATION_METRIC',
|
||||||
|
TOGGLE_TEAM_CONVERSATION_METRIC_LOADING:
|
||||||
|
'TOGGLE_TEAM_CONVERSATION_METRIC_LOADING',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ class SearchService
|
|||||||
.where("cast(conversations.display_id as text) ILIKE :search OR contacts.name ILIKE :search OR contacts.email
|
.where("cast(conversations.display_id as text) ILIKE :search OR contacts.name ILIKE :search OR contacts.email
|
||||||
ILIKE :search OR contacts.phone_number ILIKE :search OR contacts.identifier ILIKE :search", search: "%#{search_query}%")
|
ILIKE :search OR contacts.phone_number ILIKE :search OR contacts.identifier ILIKE :search", search: "%#{search_query}%")
|
||||||
.order('conversations.created_at DESC')
|
.order('conversations.created_at DESC')
|
||||||
.limit(10)
|
.page(params[:page])
|
||||||
|
.per(15)
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_messages
|
def filter_messages
|
||||||
@@ -38,13 +39,14 @@ class SearchService
|
|||||||
.where('messages.content ILIKE :search', search: "%#{search_query}%")
|
.where('messages.content ILIKE :search', search: "%#{search_query}%")
|
||||||
.where('created_at >= ?', 3.months.ago)
|
.where('created_at >= ?', 3.months.ago)
|
||||||
.reorder('created_at DESC')
|
.reorder('created_at DESC')
|
||||||
.limit(10)
|
.page(params[:page])
|
||||||
|
.per(15)
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_contacts
|
def filter_contacts
|
||||||
@contacts = current_account.contacts.where(
|
@contacts = current_account.contacts.where(
|
||||||
"name ILIKE :search OR email ILIKE :search OR phone_number
|
"name ILIKE :search OR email ILIKE :search OR phone_number
|
||||||
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
|
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
|
||||||
).resolved_contacts.order_on_last_activity_at('desc').limit(10)
|
).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ class Telegram::UpdateMessageService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update_message
|
def update_message
|
||||||
@message.update!(content: params[:edited_message][:text])
|
edited_message = params[:edited_message]
|
||||||
|
|
||||||
|
if edited_message[:text].present?
|
||||||
|
@message.update!(content: edited_message[:text])
|
||||||
|
elsif edited_message[:caption].present?
|
||||||
|
@message.update!(content: edited_message[:caption])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||||
|
|
||||||
<% headers = [
|
<% headers = [
|
||||||
I18n.t('reports.agent_csv.agent_name'),
|
I18n.t('reports.agent_csv.agent_name'),
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
I18n.t('reports.agent_csv.resolution_count')
|
I18n.t('reports.agent_csv.resolution_count')
|
||||||
]
|
]
|
||||||
%>
|
%>
|
||||||
<%= CSVSafe.generate_line headers -%>
|
<%= CSV.generate_line headers -%>
|
||||||
<% @report_data.each do |row| %>
|
<% @report_data.each do |row| %>
|
||||||
<%= CSVSafe.generate_line row -%>
|
<%= CSV.generate_line row -%>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<%= CSV.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
|
<%= CSV.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
|
||||||
|
|
||||||
<% @report_data.each do |row| %>
|
<% @report_data.each do |row| %>
|
||||||
<%= CSVSafe.generate_line row -%>
|
<%= CSV.generate_line row -%>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||||
|
|
||||||
<% headers = [
|
<% headers = [
|
||||||
I18n.t('reports.inbox_csv.inbox_name'),
|
I18n.t('reports.inbox_csv.inbox_name'),
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
I18n.t('reports.inbox_csv.avg_resolution_time')
|
I18n.t('reports.inbox_csv.avg_resolution_time')
|
||||||
]
|
]
|
||||||
%>
|
%>
|
||||||
<%= CSVSafe.generate_line headers -%>
|
<%= CSV.generate_line headers -%>
|
||||||
<% @report_data.each do |row| %>
|
<% @report_data.each do |row| %>
|
||||||
<%= CSVSafe.generate_line row -%>
|
<%= CSV.generate_line row -%>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||||
|
|
||||||
<% headers = [
|
<% headers = [
|
||||||
I18n.t('reports.label_csv.label_title'),
|
I18n.t('reports.label_csv.label_title'),
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
I18n.t('reports.label_csv.avg_resolution_time')
|
I18n.t('reports.label_csv.avg_resolution_time')
|
||||||
]
|
]
|
||||||
%>
|
%>
|
||||||
<%= CSVSafe.generate_line headers -%>
|
<%= CSV.generate_line headers -%>
|
||||||
<% @report_data.each do |row| %>
|
<% @report_data.each do |row| %>
|
||||||
<%= CSVSafe.generate_line row -%>
|
<%= CSV.generate_line row -%>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||||
|
|
||||||
<% headers = [
|
<% headers = [
|
||||||
I18n.t('reports.team_csv.team_name'),
|
I18n.t('reports.team_csv.team_name'),
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
I18n.t('reports.team_csv.resolution_count')
|
I18n.t('reports.team_csv.resolution_count')
|
||||||
]
|
]
|
||||||
%>
|
%>
|
||||||
<%= CSVSafe.generate_line headers -%>
|
<%= CSV.generate_line headers -%>
|
||||||
<% @report_data.each do |row| %>
|
<% @report_data.each do |row| %>
|
||||||
<%= CSVSafe.generate_line row -%>
|
<%= CSV.generate_line row -%>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -336,6 +336,12 @@ Rails.application.routes.draw do
|
|||||||
get :bot_metrics
|
get :bot_metrics
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
resources :live_reports, only: [] do
|
||||||
|
collection do
|
||||||
|
get :conversation_metrics
|
||||||
|
get :grouped_conversation_metrics
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ function install_dependencies() {
|
|||||||
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
|
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
|
||||||
mkdir -p /etc/apt/keyrings
|
mkdir -p /etc/apt/keyrings
|
||||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||||
NODE_MAJOR=20
|
NODE_MAJOR=23
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
|
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
|
||||||
echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg 16" > /etc/apt/sources.list.d/pgdg.list
|
echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg 16" > /etc/apt/sources.list.d/pgdg.list
|
||||||
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
|
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
|
||||||
@@ -779,15 +779,15 @@ function upgrade_node() {
|
|||||||
# Parse major version number
|
# Parse major version number
|
||||||
major_version=$(echo "$current_version" | cut -d. -f1)
|
major_version=$(echo "$current_version" | cut -d. -f1)
|
||||||
|
|
||||||
if [ "$major_version" -ge 20 ]; then
|
if [ "$major_version" -ge 23 ]; then
|
||||||
echo "Node.js is already version $current_version (>= 20.x). Skipping Node.js upgrade."
|
echo "Node.js is already version $current_version (>= 23.x). Skipping Node.js upgrade."
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Upgrading Node.js version to v20.x"
|
echo "Upgrading Node.js version to v23.x"
|
||||||
mkdir -p /etc/apt/keyrings
|
mkdir -p /etc/apt/keyrings
|
||||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||||
NODE_MAJOR=20
|
NODE_MAJOR=23
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
|
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
|
||||||
|
|
||||||
apt-get update
|
apt-get update
|
||||||
|
|||||||
+31
-12
@@ -1,6 +1,12 @@
|
|||||||
# pre-build stage
|
# pre-build stage
|
||||||
|
FROM node:23-alpine as node
|
||||||
FROM ruby:3.3.3-alpine3.19 AS pre-builder
|
FROM ruby:3.3.3-alpine3.19 AS pre-builder
|
||||||
|
|
||||||
|
ARG NODE_VERSION="23.7.0"
|
||||||
|
ARG PNPM_VERSION="10.2.0"
|
||||||
|
ENV NODE_VERSION=${NODE_VERSION}
|
||||||
|
ENV PNPM_VERSION=${PNPM_VERSION}
|
||||||
|
|
||||||
# ARG default to production settings
|
# ARG default to production settings
|
||||||
# For development docker-compose file overrides ARGS
|
# For development docker-compose file overrides ARGS
|
||||||
ARG BUNDLE_WITHOUT="development:test"
|
ARG BUNDLE_WITHOUT="development:test"
|
||||||
@@ -25,18 +31,24 @@ RUN apk update && apk add --no-cache \
|
|||||||
tzdata \
|
tzdata \
|
||||||
postgresql-dev \
|
postgresql-dev \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
nodejs=20.15.1-r0 \
|
|
||||||
git \
|
git \
|
||||||
|
curl \
|
||||||
|
xz \
|
||||||
&& mkdir -p /var/app \
|
&& mkdir -p /var/app \
|
||||||
&& gem install bundler
|
&& gem install bundler
|
||||||
|
|
||||||
# Install pnpm and configure environment
|
COPY --from=node /usr/local/bin/node /usr/local/bin/
|
||||||
RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh - \
|
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||||
&& echo 'export PNPM_HOME="/root/.local/share/pnpm"' >> /root/.shrc \
|
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||||
&& echo 'export PATH="$PNPM_HOME:$PATH"' >> /root/.shrc \
|
&& ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||||
&& export PNPM_HOME="/root/.local/share/pnpm" \
|
|
||||||
&& export PATH="$PNPM_HOME:$PATH" \
|
RUN npm install -g pnpm@${PNPM_VERSION}
|
||||||
&& pnpm --version
|
|
||||||
|
RUN echo 'export PNPM_HOME="/root/.local/share/pnpm"' >> /root/.shrc \
|
||||||
|
&& echo 'export PATH="$PNPM_HOME:$PATH"' >> /root/.shrc \
|
||||||
|
&& export PNPM_HOME="/root/.local/share/pnpm" \
|
||||||
|
&& export PATH="$PNPM_HOME:$PATH" \
|
||||||
|
&& pnpm --version
|
||||||
|
|
||||||
# Persist the environment variables in Docker
|
# Persist the environment variables in Docker
|
||||||
ENV PNPM_HOME="/root/.local/share/pnpm"
|
ENV PNPM_HOME="/root/.local/share/pnpm"
|
||||||
@@ -86,6 +98,10 @@ RUN rm -rf /gems/ruby/3.3.0/cache/*.gem \
|
|||||||
# final build stage
|
# final build stage
|
||||||
FROM ruby:3.3.3-alpine3.19
|
FROM ruby:3.3.3-alpine3.19
|
||||||
|
|
||||||
|
ARG NODE_VERSION="23.7.0"
|
||||||
|
ARG PNPM_VERSION="10.2.0"
|
||||||
|
ENV NODE_VERSION=${NODE_VERSION}
|
||||||
|
ENV PNPM_VERSION=${PNPM_VERSION}
|
||||||
|
|
||||||
ARG BUNDLE_WITHOUT="development:test"
|
ARG BUNDLE_WITHOUT="development:test"
|
||||||
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
|
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
|
||||||
@@ -114,11 +130,14 @@ RUN apk update && apk add --no-cache \
|
|||||||
vips \
|
vips \
|
||||||
&& gem install bundler
|
&& gem install bundler
|
||||||
|
|
||||||
|
COPY --from=node /usr/local/bin/node /usr/local/bin/
|
||||||
|
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||||
|
|
||||||
RUN if [ "$RAILS_ENV" != "production" ]; then \
|
RUN if [ "$RAILS_ENV" != "production" ]; then \
|
||||||
apk add --no-cache nodejs=20.15.1-r0; \
|
apk add --no-cache curl \
|
||||||
# Install pnpm and configure environment
|
&& ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||||
wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh - \
|
&& ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
|
||||||
&& source /root/.shrc \
|
&& npm install -g pnpm@${PNPM_VERSION} \
|
||||||
&& pnpm --version; \
|
&& pnpm --version; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob
|
|||||||
|
|
||||||
def resolve_captain_conversations
|
def resolve_captain_conversations
|
||||||
CaptainInbox.all.find_each(batch_size: 100) do |captain_inbox|
|
CaptainInbox.all.find_each(batch_size: 100) do |captain_inbox|
|
||||||
Captain::InboxPendingConversationsResolutionJob.perform_later(captain_inbox.inbox)
|
inbox = captain_inbox.inbox
|
||||||
|
|
||||||
|
next if inbox.email?
|
||||||
|
|
||||||
|
Captain::InboxPendingConversationsResolutionJob.perform_later(
|
||||||
|
inbox
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ class Captain::Llm::ConversationFaqService < Captain::Llm::BaseOpenAiService
|
|||||||
@content = conversation.to_llm_text
|
@content = conversation.to_llm_text
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Generates and deduplicates FAQs from conversation content
|
||||||
|
# Skips processing if there was no human interaction
|
||||||
def generate_and_deduplicate
|
def generate_and_deduplicate
|
||||||
|
return [] if no_human_interaction?
|
||||||
|
|
||||||
new_faqs = generate
|
new_faqs = generate
|
||||||
return [] if new_faqs.empty?
|
return [] if new_faqs.empty?
|
||||||
|
|
||||||
@@ -21,6 +25,10 @@ class Captain::Llm::ConversationFaqService < Captain::Llm::BaseOpenAiService
|
|||||||
|
|
||||||
attr_reader :content, :conversation, :assistant
|
attr_reader :content, :conversation, :assistant
|
||||||
|
|
||||||
|
def no_human_interaction?
|
||||||
|
conversation.first_reply_created_at.nil?
|
||||||
|
end
|
||||||
|
|
||||||
def find_and_separate_duplicates(faqs)
|
def find_and_separate_duplicates(faqs)
|
||||||
duplicate_faqs = []
|
duplicate_faqs = []
|
||||||
unique_faqs = []
|
unique_faqs = []
|
||||||
|
|||||||
+9
-8
@@ -111,7 +111,7 @@
|
|||||||
"@iconify-json/ri": "^1.2.3",
|
"@iconify-json/ri": "^1.2.3",
|
||||||
"@iconify-json/teenyicons": "^1.2.1",
|
"@iconify-json/teenyicons": "^1.2.1",
|
||||||
"@size-limit/file": "^8.2.4",
|
"@size-limit/file": "^8.2.4",
|
||||||
"@vitest/coverage-v8": "2.0.1",
|
"@vitest/coverage-v8": "3.0.5",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
@@ -134,13 +134,13 @@
|
|||||||
"prosemirror-model": "^1.22.3",
|
"prosemirror-model": "^1.22.3",
|
||||||
"size-limit": "^8.2.4",
|
"size-limit": "^8.2.4",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"vite": "^5.4.8",
|
"vite": "^5.4.12",
|
||||||
"vite-plugin-ruby": "^5.0.0",
|
"vite-plugin-ruby": "^5.0.0",
|
||||||
"vitest": "2.0.1"
|
"vitest": "3.0.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20.x",
|
"node": "23.x",
|
||||||
"pnpm": "9.x"
|
"pnpm": "10.x"
|
||||||
},
|
},
|
||||||
"husky": {
|
"husky": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
@@ -150,8 +150,8 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite-node": "2.0.1",
|
"vite-node": "2.0.1",
|
||||||
"vite": "5.4.8",
|
"vite": "5.4.12",
|
||||||
"vitest": "2.0.1"
|
"vitest": "3.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
@@ -162,5 +162,6 @@
|
|||||||
"*.scss": [
|
"*.scss": [
|
||||||
"scss-lint"
|
"scss-lint"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@10.2.0+sha512.0d27364e0139c6aadeed65ada153135e0ca96c8da42123bd50047f961339dc7a758fc2e944b428f52be570d1bd3372455c1c65fa2e7aa0bfbf931190f9552001"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+322
-367
File diff suppressed because it is too large
Load Diff
@@ -91,16 +91,15 @@ RSpec.describe 'Contacts API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'returns all contacts with country name desc order with null values at last' do
|
it 'returns all contacts with country name desc order with null values at last' do
|
||||||
|
contact_from_albania = create(:contact, :with_email, account: account, additional_attributes: { country_code: 'AL', country: 'Albania' })
|
||||||
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=country",
|
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=country",
|
||||||
headers: admin.create_new_auth_token,
|
headers: admin.create_new_auth_token,
|
||||||
as: :json
|
as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
response_body = response.parsed_body
|
response_body = response.parsed_body
|
||||||
# TODO: this spec has been flaky for a while, so adding a debug statement to see the response
|
expect(response_body['payload'].first['email']).to eq(contact_from_albania.email)
|
||||||
Rails.logger.info(response_body)
|
expect(response_body['payload'].first['id']).to eq(contact_from_albania.id)
|
||||||
expect(response_body['payload'].first['email']).to eq(contact.email)
|
|
||||||
expect(response_body['payload'].first['id']).to eq(contact.id)
|
|
||||||
expect(response_body['payload'].last['email']).to eq(contact_4.email)
|
expect(response_body['payload'].last['email']).to eq(contact_4.email)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+32
-17
@@ -1,29 +1,44 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do
|
RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do
|
||||||
let!(:account_with_bot) { create(:account) }
|
|
||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
let(:assistant) { create(:captain_assistant, account: account_with_bot) }
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
|
|
||||||
let!(:account_without_bot) { create(:account) }
|
|
||||||
let!(:inbox_with_bot) { create(:inbox, account: account_with_bot) }
|
|
||||||
let!(:inbox_without_bot) { create(:inbox, account: account_without_bot) }
|
|
||||||
|
|
||||||
describe '#perform - captain resolutions' do
|
describe '#perform - captain resolutions' do
|
||||||
before do
|
context 'when handling different inbox types' do
|
||||||
create(:captain_inbox, captain_assistant: assistant, inbox: inbox_with_bot)
|
let!(:regular_inbox) { create(:inbox, account: account) }
|
||||||
|
let!(:email_inbox) { create(:inbox, :with_email, account: account) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox)
|
||||||
|
create(:captain_inbox, captain_assistant: assistant, inbox: email_inbox)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'enqueues resolution jobs only for non-email inboxes with captain enabled' do
|
||||||
|
expect do
|
||||||
|
described_class.perform_now
|
||||||
|
end.to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob)
|
||||||
|
.with(regular_inbox)
|
||||||
|
.exactly(:once)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not enqueue resolution jobs for email inboxes even with captain enabled' do
|
||||||
|
expect do
|
||||||
|
described_class.perform_now
|
||||||
|
end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob)
|
||||||
|
.with(email_inbox)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'enqueues resolution jobs only for inboxes with captain enabled' do
|
context 'when inbox has no captain enabled' do
|
||||||
expect do
|
let!(:inbox_without_captain) { create(:inbox, account: create(:account)) }
|
||||||
described_class.perform_now
|
|
||||||
end.to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob).with(inbox_with_bot).and have_enqueued_job.exactly(:once)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not enqueue resolution jobs for inboxes without captain enabled' do
|
it 'does not enqueue resolution jobs' do
|
||||||
expect do
|
expect do
|
||||||
described_class.perform_now
|
described_class.perform_now
|
||||||
end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob).with(inbox_without_bot)
|
end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob)
|
||||||
|
.with(inbox_without_captain)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ require 'rails_helper'
|
|||||||
|
|
||||||
RSpec.describe Captain::Llm::ConversationFaqService do
|
RSpec.describe Captain::Llm::ConversationFaqService do
|
||||||
let(:captain_assistant) { create(:captain_assistant) }
|
let(:captain_assistant) { create(:captain_assistant) }
|
||||||
let(:conversation) { create(:conversation) }
|
let(:conversation) { create(:conversation, first_reply_created_at: Time.zone.now) }
|
||||||
let(:service) { described_class.new(captain_assistant, conversation) }
|
let(:service) { described_class.new(captain_assistant, conversation) }
|
||||||
let(:client) { instance_double(OpenAI::Client) }
|
let(:client) { instance_double(OpenAI::Client) }
|
||||||
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
|
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
|
||||||
@@ -57,6 +57,14 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'without human interaction' do
|
||||||
|
let(:conversation) { create(:conversation) }
|
||||||
|
|
||||||
|
it 'returns an empty array without generating FAQs' do
|
||||||
|
expect(service.generate_and_deduplicate).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when finding duplicates' do
|
context 'when finding duplicates' do
|
||||||
let(:existing_response) do
|
let(:existing_response) do
|
||||||
create(:captain_assistant_response, assistant: captain_assistant, question: 'Similar question', answer: 'Similar answer')
|
create(:captain_assistant_response, assistant: captain_assistant, question: 'Similar question', answer: 'Similar answer')
|
||||||
|
|||||||
@@ -9,5 +9,10 @@ FactoryBot.define do
|
|||||||
after(:create) do |inbox|
|
after(:create) do |inbox|
|
||||||
inbox.channel.save!
|
inbox.channel.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
trait :with_email do
|
||||||
|
channel { FactoryBot.build(:channel_email, account: account) }
|
||||||
|
name { 'Email Inbox' }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,41 +2,57 @@ require 'rails_helper'
|
|||||||
|
|
||||||
describe Telegram::UpdateMessageService do
|
describe Telegram::UpdateMessageService do
|
||||||
let!(:telegram_channel) { create(:channel_telegram) }
|
let!(:telegram_channel) { create(:channel_telegram) }
|
||||||
let!(:update_params) do
|
let(:common_message_params) do
|
||||||
{
|
{
|
||||||
'update_id': 2_323_484,
|
'from': {
|
||||||
'edited_message': {
|
'id': 123,
|
||||||
|
'username': 'sojan'
|
||||||
|
},
|
||||||
|
'chat': {
|
||||||
|
'id': 789,
|
||||||
|
'type': 'private'
|
||||||
|
},
|
||||||
|
'date': Time.now.to_i,
|
||||||
|
'edit_date': Time.now.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:text_update_params) do
|
||||||
|
{
|
||||||
|
'update_id': 1,
|
||||||
|
'edited_message': common_message_params.merge(
|
||||||
'message_id': 48,
|
'message_id': 48,
|
||||||
'from': {
|
|
||||||
'id': 512_313_123_171_248,
|
|
||||||
'is_bot': false,
|
|
||||||
'first_name': 'Sojan',
|
|
||||||
'last_name': 'Jose',
|
|
||||||
'username': 'sojan'
|
|
||||||
},
|
|
||||||
'chat': {
|
|
||||||
'id': 517_123_213_211_248,
|
|
||||||
'first_name': 'Sojan',
|
|
||||||
'last_name': 'Jose',
|
|
||||||
'username': 'sojan',
|
|
||||||
'type': 'private'
|
|
||||||
},
|
|
||||||
'date': 1_680_088_034,
|
|
||||||
'edit_date': 1_680_088_056,
|
|
||||||
'text': 'updated message'
|
'text': 'updated message'
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:caption_update_params) do
|
||||||
|
{
|
||||||
|
'update_id': 2,
|
||||||
|
'edited_message': common_message_params.merge(
|
||||||
|
'message_id': 49,
|
||||||
|
'caption': 'updated caption'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
context 'when valid update message params' do
|
context 'when valid update message params' do
|
||||||
it 'updates the appropriate message' do
|
let(:contact_inbox) { create(:contact_inbox, inbox: telegram_channel.inbox, source_id: common_message_params[:chat][:id]) }
|
||||||
contact_inbox = create(:contact_inbox, inbox: telegram_channel.inbox, source_id: update_params[:edited_message][:chat][:id])
|
let(:conversation) { create(:conversation, contact_inbox: contact_inbox) }
|
||||||
conversation = create(:conversation, contact_inbox: contact_inbox)
|
|
||||||
message = create(:message, conversation: conversation, source_id: update_params[:edited_message][:message_id])
|
it 'updates the message text when text is present' do
|
||||||
described_class.new(inbox: telegram_channel.inbox, params: update_params.with_indifferent_access).perform
|
message = create(:message, conversation: conversation, source_id: text_update_params[:edited_message][:message_id])
|
||||||
|
described_class.new(inbox: telegram_channel.inbox, params: text_update_params.with_indifferent_access).perform
|
||||||
expect(message.reload.content).to eq('updated message')
|
expect(message.reload.content).to eq('updated message')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'updates the message caption when caption is present' do
|
||||||
|
message = create(:message, conversation: conversation, source_id: caption_update_params[:edited_message][:message_id])
|
||||||
|
described_class.new(inbox: telegram_channel.inbox, params: caption_update_params.with_indifferent_access).perform
|
||||||
|
expect(message.reload.content).to eq('updated caption')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when invalid update message params' do
|
context 'when invalid update message params' do
|
||||||
|
|||||||
Reference in New Issue
Block a user