Compare commits

..
Author SHA1 Message Date
Muhsin Keloth 45f4a54d86 fix: Scroll issue 2024-04-15 22:12:20 +05:30
Muhsin Keloth cb35207c26 feat: Complete basic design 2024-04-15 11:39:10 +05:30
Muhsin Keloth b7353eed4a feat: Add user profile container 2024-04-15 10:28:36 +05:30
Muhsin Keloth d04a5f1c51 add more files 2024-04-12 20:20:46 +05:30
Muhsin Keloth 2283c3505f add form 2024-04-12 15:08:42 +05:30
Muhsin Keloth 7e80d7cc3b Update Index.vue 2024-04-12 09:52:12 +05:30
Muhsin Keloth ca44d6b7a2 feat: Add more files 2024-04-12 08:34:43 +05:30
102 changed files with 1242 additions and 2073 deletions
+2 -2
View File
@@ -69,7 +69,7 @@ gem 'webpacker'
gem 'barnes'
##--- gems for authentication & authorization ---##
gem 'devise', '>= 4.9.4'
gem 'devise', '>= 4.9.3'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth'
# authorization
@@ -203,7 +203,7 @@ group :development do
gem 'rack-mini-profiler', '>= 3.2.0', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request', '>= 0.8.0'
gem 'meta_request'
end
group :test do
+16 -16
View File
@@ -149,7 +149,7 @@ GEM
multi_json (~> 1)
statsd-ruby (~> 1.1)
base64 (0.1.1)
bcrypt (3.1.20)
bcrypt (3.1.19)
bindex (0.8.1)
blingfire (0.1.8)
bootsnap (1.16.0)
@@ -194,7 +194,7 @@ GEM
irb (>= 1.5.0)
reline (>= 0.3.1)
declarative (0.0.20)
devise (4.9.4)
devise (4.9.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
@@ -366,7 +366,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.4)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -452,17 +452,17 @@ GEM
marcel (1.0.2)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.8.2)
meta_request (0.7.4)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
railties (>= 3.0.0, < 7.1)
method_source (1.0.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.6)
minitest (5.22.3)
mini_portile2 (2.8.5)
minitest (5.21.2)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -491,14 +491,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.0)
nokogiri (1.16.4)
nokogiri (1.16.2)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.4-arm64-darwin)
nokogiri (1.16.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.4-x86_64-darwin)
nokogiri (1.16.2-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.4-x86_64-linux)
nokogiri (1.16.2-x86_64-linux)
racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0)
@@ -608,7 +608,7 @@ GEM
thor (~> 1.0)
zeitwerk (~> 2.5)
rainbow (3.1.1)
rake (13.2.1)
rake (13.1.0)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
@@ -769,7 +769,7 @@ GEM
stripe (8.5.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.3.1)
thor (1.3.0)
tilt (2.3.0)
time_diff (0.3.0)
activesupport
@@ -828,7 +828,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.13)
zeitwerk (2.6.12)
PLATFORMS
arm64-darwin-20
@@ -867,7 +867,7 @@ DEPENDENCIES
database_cleaner
ddtrace
debug (~> 1.8)
devise (>= 4.9.4)
devise (>= 4.9.3)
devise-secure_password!
devise_token_auth
dotenv-rails
@@ -905,7 +905,7 @@ DEPENDENCIES
listen
lograge (~> 0.14.0)
maxminddb
meta_request (>= 0.8.0)
meta_request
mock_redis
neighbor
net-smtp (~> 0.3.4)
@@ -3,7 +3,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
def build
set_grouped_conversations_count
set_grouped_resolved_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
@@ -28,10 +27,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end
def set_grouped_resolved_conversations_count
@grouped_resolved_conversations_count = reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end
def group_by_key
:user_id
end
@@ -42,14 +37,12 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
def prepare_report
account.account_users.each_with_object([]) do |account_user, arr|
user_id = account_user.user_id
arr << {
id: user_id,
conversations_count: @grouped_conversations_count[user_id],
resolved_conversations_count: @grouped_resolved_conversations_count[user_id],
avg_resolution_time: @grouped_avg_resolution_time[user_id],
avg_first_response_time: @grouped_avg_first_response_time[user_id],
avg_reply_time: @grouped_avg_reply_time[user_id]
id: account_user.user_id,
conversations_count: @grouped_conversations_count[account_user.user_id],
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id],
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id],
avg_reply_time: @grouped_avg_reply_time[account_user.user_id]
}
end
end
@@ -1,60 +0,0 @@
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_resolved_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report
end
private
def set_grouped_conversations_count
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('inbox_id').count
end
def set_grouped_avg_resolution_time
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end
def set_grouped_resolved_conversations_count
@grouped_resolved_conversations_count = reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end
def group_by_key
:inbox_id
end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
end
def prepare_report
account.inboxes.each_with_object([]) do |inbox, arr|
inbox_id = inbox.id
arr << {
id: inbox_id,
conversations_count: @grouped_conversations_count[inbox_id],
resolved_conversations_count: @grouped_resolved_conversations_count[inbox_id],
avg_resolution_time: @grouped_avg_resolution_time[inbox_id],
avg_first_response_time: @grouped_avg_first_response_time[inbox_id],
avg_reply_time: @grouped_avg_reply_time[inbox_id]
}
end
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end
@@ -3,7 +3,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
def build
set_grouped_conversations_count
set_grouped_resolved_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
@@ -28,10 +27,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end
def set_grouped_resolved_conversations_count
@grouped_resolved_conversations_count = reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
end
@@ -45,7 +40,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
arr << {
id: team.id,
conversations_count: @grouped_conversations_count[team.id],
resolved_conversations_count: @grouped_resolved_conversations_count[team.id],
avg_resolution_time: @grouped_avg_resolution_time[team.id],
avg_first_response_time: @grouped_avg_first_response_time[team.id],
avg_reply_time: @grouped_avg_reply_time[team.id]
@@ -33,10 +33,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
if permitted_params[:email].present? && conversation.present?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
permitted_params[:email]
)&.deliver_later
end
head :ok
@@ -1,57 +0,0 @@
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
}
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
@@ -131,4 +131,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
summary
end
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
before_action :prepare_builder_params, only: [:agent, :team]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -10,10 +10,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::TeamSummaryBuilder)
end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder)
end
private
def check_authorization
@@ -7,7 +7,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def index
@articles = @portal.articles
search_articles
@articles = @articles.search(list_params) if list_params.present?
order_by_sort_param
@articles.page(list_params[:page]) if list_params[:page].present?
end
@@ -16,10 +16,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
private
def search_articles
@articles = @articles.search(list_params) if list_params.present?
end
def order_by_sort_param
@articles = if list_params[:sort].present? && list_params[:sort] == 'views'
@articles.order_by_views
@@ -55,5 +51,3 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
ChatwootMarkdownRenderer.new(content).render_article
end
end
Public::Api::V1::Portals::ArticlesController.prepend_mod_with('Public::Api::V1::Portals::ArticlesController')
@@ -94,8 +94,7 @@ module Api::V2::Accounts::HeatmapHelper
end
def since_timestamp(date)
number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days
(date - number_of_days).to_i.to_s
(date - 6.days).to_i.to_s
end
def until_timestamp(date)
@@ -1,20 +0,0 @@
/* 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();
+2 -2
View File
@@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient {
});
}
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
getConversationTrafficCSV() {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
params: { timezone_offset: getTimeOffset() },
});
}
@@ -13,7 +13,6 @@ class SLAReportsAPI extends ApiClient {
inbox_id,
team_id,
sla_policy_id,
label_list,
page,
} = {}) {
return axios.get(this.url, {
@@ -24,7 +23,6 @@ class SLAReportsAPI extends ApiClient {
inbox_id,
team_id,
sla_policy_id,
label_list,
page,
},
});
@@ -56,7 +54,6 @@ class SLAReportsAPI extends ApiClient {
assigned_agent_id,
inbox_id,
team_id,
label_list,
sla_policy_id,
} = {}) {
return axios.get(`${this.url}/metrics`, {
@@ -65,7 +62,6 @@ class SLAReportsAPI extends ApiClient {
until: to,
assigned_agent_id,
inbox_id,
label_list,
team_id,
sla_policy_id,
},
@@ -35,7 +35,6 @@ describe('#SLAReports API', () => {
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
label_list: ['label1'],
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/applied_slas', {
params: {
@@ -46,7 +45,6 @@ describe('#SLAReports API', () => {
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
label_list: ['label1'],
},
});
});
@@ -58,7 +56,6 @@ describe('#SLAReports API', () => {
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
label_list: ['label1'],
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/applied_slas/metrics',
@@ -70,7 +67,6 @@ describe('#SLAReports API', () => {
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
label_list: ['label1'],
},
}
);
@@ -1,40 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
}
getTeamReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/team`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getAgentReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/agent`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getInboxReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/inbox`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
}
export default new SummaryReportsAPI();
@@ -1,13 +1,6 @@
@import '~vue2-datepicker/scss/index';
.date-picker {
// To be removed one SLA reports date picker is created
&.small {
.mx-input {
@apply h-8 text-sm;
}
}
&.no-margin {
.mx-input {
@apply mb-0;
@@ -153,29 +153,6 @@
}
.multiselect-wrap--small {
// To be removed one SLA reports date picker is created
&.tiny {
.multiselect.no-margin {
@apply min-h-[32px];
}
.multiselect__select {
@apply min-h-[32px] h-8;
&::before {
@apply top-[60%];
}
}
.multiselect__tags {
@apply min-h-[32px] max-h-[32px];
.multiselect__single {
@apply pt-1 pb-1;
}
}
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@@ -1,42 +1,32 @@
<template>
<div
class="flex flex-col min-w-[15rem] max-h-[21.25rem] max-w-[23.75rem] rounded-md border border-solid border-slate-75 dark:border-slate-600"
class="flex flex-col min-w-[15rem] max-h-[21.25rem] w-[23rem] rounded-md border border-solid border-slate-75 dark:border-slate-600"
:class="{
'bg-woot-25 dark:bg-slate-700 border border-solid border-woot-300 dark:border-woot-400':
active,
'border border-solid border-woot-300 dark:border-woot-400': active,
}"
>
<div
class="flex justify-between items-center px-2 w-full h-10 bg-slate-50 dark:bg-slate-900 rounded-t-[5px] border-b border-solid border-slate-50 dark:border-slate-600"
:class="{
'bg-woot-50 border-b border-solid border-woot-75 dark:border-woot-700':
active,
}"
class="flex justify-between items-center px-2 w-full h-10 rounded-t-[5px]"
>
<div class="items-center flex font-medium p-1 text-sm">{{ heading }}</div>
<fluent-icon
<div class="flex items-center p-1 text-sm font-medium">{{ heading }}</div>
<!-- <fluent-icon
v-if="active"
icon="checkmark-circle"
type="solid"
size="24"
class="text-woot-500 dark:text-woot-500"
/>
/> -->
<div>
<input type="radio" name="hotkey" value="enter" :checked="active" />
</div>
</div>
<div
class="text-slate-700 dark:text-slate-200 text-xs leading-[1.4] px-3 pt-3 pb-0 text-start"
class="text-slate-700 dark:text-slate-200 text-xs leading-[1.4] px-3 pb-0 text-start"
>
{{ content }}
</div>
<div v-if="src" class="p-3">
<img
:src="src"
class="border border-solid rounded-md"
:class="
active
? 'border-woot-75 dark:border-woot-700'
: 'border-slate-50 dark:border-slate-600'
"
/>
<img :src="src" class="object-cover w-full" />
</div>
<slot v-else />
</div>
@@ -82,7 +82,7 @@
</template>
<script setup>
import { computed } from 'vue';
import { computed, defineEmits } from 'vue';
// Props
const props = defineProps({
@@ -138,11 +138,7 @@
"groupBy": "Year"
}
],
"BUSINESS_HOURS": "Business Hours",
"FILTER_ACTIONS": {
"CLEAR_FILTER": "Clear filter",
"EMPTY_LIST": "No results found"
}
"BUSINESS_HOURS": "Business Hours"
},
"AGENT_REPORTS": {
"HEADER": "Agents Overview",
@@ -494,17 +490,6 @@
"STATUS": "Status"
}
},
"TEAM_CONVERSATIONS": {
"HEADER": "Conversations by teams",
"LOADING_MESSAGE": "Loading team metrics...",
"NO_AGENTS": "There is no data available",
"TABLE_HEADER": {
"AGENT": "Team",
"OPEN": "open",
"UNATTENDED": "Unattended",
"STATUS": "Status"
}
},
"AGENT_STATUS": {
"HEADER": "Agent status",
"ONLINE": "Online",
@@ -527,26 +512,6 @@
"LOADING": "Loading SLA data...",
"DOWNLOAD_SLA_REPORTS": "Download SLA reports",
"DOWNLOAD_FAILED": "Failed to download SLA Reports",
"DROPDOWN": {
"ADD_FIlTER": "Add filter",
"CLEAR_ALL": "Clear all",
"CLEAR_FILTER": "Clear filter",
"EMPTY_LIST": "No results found",
"NO_FILTER": "No filters available",
"SEARCH": "Search filter",
"INPUT_PLACEHOLDER": {
"SLA": "SLA name",
"AGENTS": "Agent name",
"INBOXES": "Inbox name",
"LABELS": "Label name",
"TEAMS": "Team name"
},
"SLA": "SLA Policy",
"INBOXES": "Inbox",
"AGENTS": "Agent",
"LABELS": "Label",
"TEAMS": "Team"
},
"METRICS": {
"HIT_RATE": {
"LABEL": "Hit Rate",
@@ -0,0 +1,55 @@
<template>
<div class="avatar-container" :style="style" aria-hidden="true">
<slot>{{ userInitial }}</slot>
</div>
</template>
<script>
export default {
name: 'Avatar',
props: {
username: {
type: String,
default: '',
},
size: {
type: Number,
default: 40,
},
},
computed: {
style() {
return {
fontSize: `${Math.floor(this.size / 2.5)}px`,
};
},
userInitial() {
const parts = this.username.split(/[ -]/);
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
initials = initials.replace(/[a-z]+/g, '');
}
initials = initials.substring(0, 2).toUpperCase();
return initials;
},
},
};
</script>
<style scoped>
@tailwind components;
@layer components {
.avatar-color {
background-image: linear-gradient(to top, #c2e1ff 0%, #d6ebff 100%);
}
.dark-avatar-color {
background-image: linear-gradient(to top, #135899 0%, #135899 100%);
}
}
.avatar-container {
@apply flex leading-[100%] font-medium items-center justify-center text-center cursor-default avatar-color dark:dark-avatar-color text-woot-600 dark:text-woot-200;
}
</style>
@@ -0,0 +1,410 @@
<template>
<div class="flex items-center w-full overflow-y-auto">
<div
class="flex flex-col h-full p-5 pt-16 mx-auto my-0 bg-white dark:bg-slate-900 font-inter"
>
<div class="flex flex-col gap-8 sm:max-w-3xl">
<h2 class="text-2xl font-medium text-slate-900 dark:text-white">
Profile Settings
</h2>
<user-profile-picture username="John Doe" size="72px" />
<personal-wrapper
header="Personal message signature"
description="Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes."
:show-action-button="true"
button-text="Save message signature"
>
<template #settingsItem>
<message-signature />
</template>
</personal-wrapper>
<personal-wrapper
header="Hot key to send messages"
description="You can select a hotkey (either Enter or Cmd/Ctrl+Enter) based on your
preference of writing."
:show-action-button="false"
>
<template #settingsItem>
<div class="flex flex-row justify-between gap-4">
<button
v-for="keyOption in keyOptions"
:key="keyOption.key"
class="p-0 cursor-pointer"
>
<preview-card
:heading="keyOption.heading"
:content="keyOption.content"
:src="keyOption.src"
:active="keyOption.key === 'enter'"
/>
</button>
</div>
</template>
</personal-wrapper>
<div class="flex flex-col w-full gap-4">
<h4 class="text-lg font-medium text-slate-900 dark:text-slate-25">
Change password
</h4>
<div class="flex flex-row items-center justify-between gap-4">
<div class="flex-grow h-0.5 bg-slate-50 dark:bg-slate-700" />
</div>
<form-input
name="current_password"
class="flex-1"
:class="{ error: false }"
label="Current password"
placeholder="Please enter your current password"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<form-input
name="new_password"
class="flex-1"
:class="{ error: false }"
label="New password"
placeholder="Please enter your new password"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<form-input
name="confirm_password"
class="flex-1"
:class="{ error: false }"
label="Confirm password"
placeholder="Please confirm your new password"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<woot-button
color-scheme="primary"
class="rounded-xl w-fit"
@click="$emit('click')"
>
Update password
</woot-button>
</div>
<personal-wrapper
header="Audio notifications"
description="Enable audio notifications in dashboard for new messages and conversations."
:show-action-button="false"
>
<template #settingsItem>
<form-select
name="timezone"
spacing="compact"
label="Alert tone"
placeholder="Ding"
class="max-w-xl"
>
<option
v-for="file in audioFiles"
:key="file.label"
:value="file.value"
>
{{ file.label }}
</option>
</form-select>
<div>
<label
class="flex justify-between pb-1 text-sm font-medium leading-6 text-slate-900 dark:text-white"
>
Alert events for conversations
</label>
<div
class="flex flex-row justify-between h-10 max-w-xl p-2 border border-solid rounded-md border-slate-75 dark:border-slate-600"
>
<div
class="flex flex-row items-center justify-center gap-1 border-r border-slate-75 grow"
>
<input type="radio" name="hotkey" value="enter" checked />
<label>None</label>
</div>
<div
class="flex flex-row items-center justify-center gap-1 border-r border-slate-75 grow"
>
<input type="radio" name="hotkey" value="enter" />
<label>Assigned</label>
</div>
<div
class="flex flex-row items-center justify-center gap-1 grow"
>
<input type="radio" name="hotkey" value="enter" />
<label>All</label>
</div>
</div>
</div>
<div>
<label
class="flex justify-between pb-1 text-sm font-medium leading-6 text-slate-900 dark:text-white"
>
Alert conditions
</label>
<div class="flex flex-row items-center justify-start gap-2">
<input
type="checkbox"
name="send"
:checked="true"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
<label>
Send audio alerts only if the browser window is not active
</label>
</div>
<div class="flex flex-row items-center justify-start gap-2">
<input
type="checkbox"
name="send"
:checked="false"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
<label>
Send alerts every 30s until all the assigned conversations are
read
</label>
</div>
</div>
</template>
</personal-wrapper>
<personal-wrapper
header="Notification preferences"
:show-action-button="false"
>
<template #settingsItem>
<div>
<!-- Draw a line -->
<div class="flex flex-row items-center justify-between gap-4">
<div class="flex-grow h-0.5 bg-slate-50 dark:bg-slate-700" />
</div>
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<table-header-cell :span="6" label="Notification type" />
<table-header-cell :span="3" label="Email" />
<table-header-cell :span="3" label="Push notification" />
</div>
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<span class="text-slate-700 dark:text-slate-200">
A new conversation is created
</span>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="true"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="false"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
</div>
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<span class="text-slate-700 dark:text-slate-200">
Conversation is assigned to you
</span>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="true"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="false"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
</div>
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<span class="text-slate-700 dark:text-slate-200">
Conversation is assigned to you
</span>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="true"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="false"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
</div>
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<span class="text-slate-700 dark:text-slate-200">
You are mentioned in a conversation
</span>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="true"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="false"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
</div>
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<span class="text-slate-700 dark:text-slate-200">
New message is created in an assigned conversation
</span>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="true"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
<div
class="flex items-center gap-2 col-span-3 py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-50 text-left rtl:text-right"
>
<input
type="checkbox"
name="email"
:checked="false"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
/>
</div>
</div>
</div>
</template>
</personal-wrapper>
<personal-wrapper
header="Access Token"
description="This token can be used if you are building an API based integration."
:show-action-button="false"
>
<template #settingsItem>
<div class="flex flex-row justify-between gap-4">
<form-input
name="access_token"
class="flex-1"
type="password"
value="1234567890"
:class="{ error: false }"
placeholder="Please enter your new password"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<woot-button
icon="copy"
variant="hollow"
color-scheme="secondary"
class="rounded-xl w-fit"
@click="$emit('click')"
>
Copy
</woot-button>
</div>
</template>
</personal-wrapper>
</div>
</div>
</div>
</template>
<script setup>
import TableHeaderCell from 'dashboard/components/widgets/TableHeaderCell.vue';
import MessageSignature from '../profile/MessageSignature.vue';
import UserProfilePicture from './UserProfilePicture.vue';
import PersonalWrapper from './PersonalWrapper.vue';
import PreviewCard from 'dashboard/components/ui/PreviewCard.vue';
import FormInput from 'v3/components/Form/Input.vue';
import FormSelect from 'v3/components/Form/Select.vue';
const keyOptions = [
{
key: 'enter',
src: '/assets/images/dashboard/profile/hot-key-enter.svg',
heading: 'Enter (↵)',
content:
'Send messages by pressing Enter key instead of clicking send button.',
},
{
key: 'cmd_enter',
src: '/assets/images/dashboard/profile/hot-key-ctrl-enter.svg',
heading: 'CMD / Ctrl + Enter (⌘ + ↵)',
content:
'Send messages by pressing Enter key instead of clicking send button.',
},
];
const audioFiles = [
{ label: 'Ding', value: 'ding' },
{ label: 'Dong', value: 'dong' },
];
</script>
@@ -0,0 +1,52 @@
<template>
<div class="flex flex-col items-start w-full gap-6">
<div
class="flex flex-col w-full gap-4 divide-y divide-slate-200 dark:divide-slate-700"
>
<h4
v-if="header"
class="text-lg font-medium text-slate-900 dark:text-slate-25"
>
{{ header }}
</h4>
<p
v-if="description"
class="pt-4 mb-0 text-sm font-normal text-slate-600 dark:text-slate-400"
>
{{ description }}
</p>
</div>
<div v-if="$slots.settingsItem" class="flex flex-col w-full gap-6">
<slot name="settingsItem" />
<woot-button
v-if="showActionButton"
color-scheme="primary"
class="rounded-xl w-fit"
@click="$emit('click')"
>
{{ buttonText }}
</woot-button>
</div>
</div>
</template>
<script setup>
defineProps({
header: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
showActionButton: {
type: Boolean,
default: true,
},
buttonText: {
type: String,
default: 'Save',
},
});
</script>
@@ -0,0 +1,218 @@
<template>
<div
class="relative rounded-xl"
:class="thumbnailBoxClass"
:style="{ height: size, width: size }"
:title="title"
>
<img
class="rounded-xl"
src="https://picsum.photos/200"
draggable="false"
:class="thumbnailClass"
@load="onImgLoad"
@error="onImgError"
/>
<div
class="absolute select-none w-6 h-6 z-10 p-1 -top-2 -right-2 border border-white dark:border-white rounded-full flex items-center justify-center bg-slate-200 dark:bg-slate-700"
>
<fluent-icon
icon="dismiss"
size="16"
class="text-slate-700 dark:text-slate-200"
/>
</div>
<div
:style="{ height: size, width: size }"
class="absolute top-0 left-0 rounded-xl select-none flex items-center justify-center bg-modal-backdrop-dark dark:bg-modal-backdrop-dark"
>
<fluent-icon
icon="avatar-upload"
size="32"
class="text-white dark:text-white"
/>
</div>
</div>
</template>
<script>
/**
* Thumbnail Component
* Src - source for round image
* Size - Size of the thumbnail
* Badge - Chat source indication { fb / telegram }
* Username - Username for avatar
*/
import Avatar from './Avatar.vue';
import { removeEmoji } from 'shared/helpers/emoji';
export default {
components: {
Avatar,
},
props: {
src: {
type: String,
default: '',
},
size: {
type: String,
default: '40px',
},
badge: {
type: String,
default: '',
},
username: {
type: String,
default: '',
},
status: {
type: String,
default: '',
},
hasBorder: {
type: Boolean,
default: false,
},
shouldShowStatusAlways: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
variant: {
type: String,
default: 'circle',
},
},
data() {
return {
hasImageLoaded: false,
imgError: false,
};
},
computed: {
userNameWithoutEmoji() {
return removeEmoji(this.username);
},
showStatusIndicator() {
if (this.shouldShowStatusAlways) return true;
return this.status === 'online' || this.status === 'busy';
},
avatarSize() {
return Number(this.size.replace(/\D+/g, ''));
},
badgeSrc() {
return {
instagram_direct_message: 'instagram-dm',
facebook: 'messenger',
'twitter-tweet': 'twitter-tweet',
'twitter-dm': 'twitter-dm',
whatsapp: 'whatsapp',
sms: 'sms',
'Channel::Line': 'line',
'Channel::Telegram': 'telegram',
'Channel::WebWidget': '',
}[this.badge];
},
badgeStyle() {
const size = Math.floor(this.avatarSize / 3);
const badgeSize = `${size + 2}px`;
const borderRadius = `${size / 2}px`;
return { width: badgeSize, height: badgeSize, borderRadius };
},
statusStyle() {
const statusSize = `${this.avatarSize / 4}px`;
return { width: statusSize, height: statusSize };
},
thumbnailClass() {
const className = this.hasBorder
? 'border border-solid border-white dark:border-slate-700/50'
: '';
const variant =
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
return `user-thumbnail ${className} ${variant}`;
},
thumbnailBoxClass() {
const boxClass = this.variant === 'circle' ? 'is-rounded' : '';
return `user-thumbnail-box ${boxClass}`;
},
shouldShowImage() {
if (!this.src) {
return false;
}
if (this.hasImageLoaded) {
return !this.imgError;
}
return false;
},
},
watch: {
src(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
},
methods: {
onImgError() {
this.imgError = true;
},
onImgLoad() {
this.hasImageLoaded = true;
},
},
};
</script>
<style lang="scss" scoped>
.user-thumbnail-box {
flex: 0 0 auto;
max-width: 100%;
position: relative;
.user-thumbnail {
height: 100%;
width: 100%;
box-sizing: border-box;
object-fit: cover;
vertical-align: initial;
}
.source-badge {
border-radius: var(--border-radius-small);
bottom: var(--space-minus-micro);
box-shadow: var(--shadow-small);
height: var(--space-slab);
padding: var(--space-micro);
position: absolute;
right: 0;
width: var(--space-slab);
@apply bg-white dark:bg-slate-900;
}
.user-online-status {
border-radius: 50%;
bottom: var(--space-micro);
&:after {
content: ' ';
}
}
.user-online-status--online {
@apply bg-green-400 dark:bg-green-400;
}
.user-online-status--busy {
@apply bg-yellow-500 dark:bg-yellow-500;
}
.user-online-status--offline {
@apply bg-slate-500 dark:bg-slate-500;
}
}
</style>
@@ -0,0 +1,130 @@
<template>
<div>
<div class="flex flex-col gap-2 mt-2">
<span class="font-medium text-slate-900 dark:text-slate-25 text-normal">
Profile picture
</span>
<div
class="relative rounded-xl h-[72px] w-[72px]"
:title="title"
@mouse-over="showUpload"
>
<img
class="rounded-xl h-[72px] w-[72px]"
src="https://i.pravatar.cc/300"
draggable="false"
@load="onImgLoad"
@error="onImgError"
/>
<div
class="absolute z-10 flex items-center justify-center w-6 h-6 p-1 border border-white rounded-full select-none -top-2 -right-2 dark:border-white bg-slate-200 dark:bg-slate-700"
>
<fluent-icon
icon="dismiss"
size="16"
class="text-slate-800 dark:text-slate-200"
/>
</div>
<div
class="absolute h-[72px] w-[72px] top-0 left-0 rounded-xl select-none flex items-center justify-center bg-modal-backdrop-dark dark:bg-modal-backdrop-dark"
>
<fluent-icon
icon="avatar-upload"
size="32"
class="text-white dark:text-white"
/>
</div>
</div>
</div>
<div class="pt-6">
<form-input
name="full_name"
class="flex-1"
:class="{ error: false }"
:label="$t('REGISTER.FULL_NAME.LABEL')"
placeholder="Enter your name"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<form-input
name="display_name"
class="flex-1"
:class="{ error: false }"
label="Display name"
placeholder="Enter your display name"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<form-input
name="email"
class="flex-1"
:class="{ error: false }"
label="Email"
placeholder="Enter your email"
:has-error="false"
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
/>
<woot-button
color-scheme="primary"
class="rounded-xl w-fit"
@click="$emit('click')"
>
Update profile
</woot-button>
</div>
</div>
</template>
<script>
import FormInput from 'v3/components/Form/Input.vue';
export default {
components: {
FormInput,
},
props: {
src: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
},
data() {
return {
hasImageLoaded: false,
imgError: false,
showUploadIcon: false,
};
},
computed: {
shouldShowImage() {
if (!this.src) {
return false;
}
if (this.hasImageLoaded) {
return !this.imgError;
}
return false;
},
},
watch: {
src(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
},
methods: {
onImgError() {
this.imgError = true;
},
onImgLoad() {
this.hasImageLoaded = true;
},
showUpload() {
this.showUploadIcon = true;
},
},
};
</script>
@@ -0,0 +1,20 @@
import { frontendURL } from '../../../../helper/URLHelper';
const Index = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/personal'),
name: 'personal_settings',
roles: ['administrator', 'agent'],
component: Index,
props: {
headerTitle: 'PROFILE_SETTINGS.TITLE',
icon: 'edit',
showNewButton: false,
showSidemenuIcon: false,
},
},
],
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 359 B

@@ -1,40 +1,14 @@
<template>
<div
class="profile--settings--row text-black-900 dark:text-slate-300 flex items-center"
>
<div class="w-1/4 py-4 pr-6 ml-0">
<h4 class="text-lg text-black-900 dark:text-slate-200">
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.TITLE') }}
</h4>
<p>{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.NOTE') }}</p>
</div>
<div class="p-4 w-[45%]">
<div>
<label for="message-signature-input">{{
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.LABEL')
}}</label>
<woot-message-editor
id="message-signature-input"
v-model="messageSignature"
class="message-editor h-[10rem]"
:is-format-mode="true"
:placeholder="
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')
"
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
:show-image-resize-toolbar="true"
/>
</div>
<woot-button
:is-loading="isUpdating"
type="button"
@click.prevent="updateSignature"
>
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</woot-button>
</div>
</div>
<woot-message-editor
id="message-signature-input"
v-model="messageSignature"
class="message-editor h-[10rem]"
:is-format-mode="true"
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
:show-image-resize-toolbar="true"
/>
</template>
<script>
@@ -99,7 +73,7 @@ export default {
<style lang="scss" scoped>
.message-editor {
@apply px-3 mb-4;
@apply px-3;
::v-deep {
.ProseMirror-menubar {
@@ -1,44 +1,19 @@
<template>
<div class="column overflow-auto">
<metric-card :is-live="false" header="All agents overview">
<woot-summary-reports
key="agent-summary-reports"
class="!p-0"
type="agent"
getter-key="agents/getAgents"
attribute-key="team_id"
action-key="summaryReports/fetchAgentSummaryReports"
summary-key="summaryReports/getAgentSummaryReports"
:download-button-label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')"
/>
</metric-card>
<metric-card
:is-live="false"
header="Agent-wise reports"
class="overflow-visible"
>
<woot-reports
key="agent-reports"
:show-download-button="false"
class="!p-0"
type="agent"
getter-key="agents/getAgents"
action-key="agents/get"
/>
</metric-card>
</div>
<woot-reports
key="agent-reports"
type="agent"
getter-key="agents/getAgents"
action-key="agents/get"
:download-button-label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')"
/>
</template>
<script>
import WootSummaryReports from './components/WootSummaryReports.vue';
import MetricCard from './components/overview/MetricCard.vue';
import WootReports from './components/WootReports.vue';
export default {
components: {
WootReports,
MetricCard,
WootSummaryReports,
},
};
</script>
@@ -1,46 +1,19 @@
<template>
<div class="column overflow-auto">
<metric-card :is-live="false" header="All inboxes overview">
<woot-summary-reports
key="inbox-view-reports"
class="!p-0"
type="inbox"
getter-key="inboxes/getInboxes"
action-key="summaryReports/fetchInboxSummaryReports"
summary-key="summaryReports/getInboxSummaryReports"
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
/>
</metric-card>
<metric-card
:is-live="false"
header="Inbox-wise reports"
class="overflow-visible"
>
<woot-reports
key="inbox-reports"
:show-download-button="false"
class="!p-0"
type="inbox"
getter-key="inboxes/getInboxes"
action-key="inboxes/get"
/>
</metric-card>
</div>
<woot-reports
key="inbox-reports"
type="inbox"
getter-key="inboxes/getInboxes"
action-key="inboxes/get"
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
/>
</template>
<script>
import WootSummaryReports from './components/WootSummaryReports.vue';
import MetricCard from './components/overview/MetricCard.vue';
import WootReports from './components/WootReports.vue';
export default {
components: {
WootReports,
MetricCard,
WootSummaryReports,
},
mounted() {
this.$store.dispatch('agents/get');
},
};
</script>
@@ -4,7 +4,24 @@
<div
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
>
<open-conversations />
<metric-card
: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="metric-content flex-1 min-w-0"
>
<h3 class="heading">
{{ name }}
</h3>
<p class="metric">{{ metric }}</p>
</div>
</metric-card>
</div>
<div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%]">
<metric-card :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
@@ -24,18 +41,6 @@
<div class="max-w-full flex flex-wrap flex-row ml-auto mr-auto">
<metric-card :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
<template #control>
<multiselect-dropdown
class="!mb-0 !w-1/3"
:options="dayFilterOptions"
:selected-item="selectedDayFilter"
multiselector-title=""
multiselector-placeholder="Date filter"
no-search-result="No filter found"
input-placeholder="Search for a filter"
:is-filter="true"
:has-thumbnail="false"
@click="onSelectDateFilter"
/>
<woot-button
icon="arrow-download"
size="small"
@@ -47,7 +52,6 @@
</woot-button>
</template>
<report-heatmap
:selected-day-filter="selectedDayFilter"
:heat-data="accountConversationHeatmap"
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
/>
@@ -60,16 +64,7 @@
:agent-metrics="agentConversationMetric"
:page-index="pageIndex"
:is-loading="uiFlags.isFetchingAgentConversationMetric"
/>
</metric-card>
</div>
<div class="row">
<metric-card :header="$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.HEADER')">
<team-table
:teams="teams"
:team-metrics="teamConversationMetric"
:page-index="pageIndex"
:is-loading="uiFlags.isFetchingTeamConversationMetric"
@page-change="onPageNumberChange"
/>
</metric-card>
</div>
@@ -77,54 +72,34 @@
</template>
<script>
import { mapGetters } from 'vuex';
import OpenConversations from './components/LiveReports/OpenConversations.vue';
import AgentTable from './components/overview/AgentTable.vue';
import TeamTable from './components/overview/TeamTable.vue';
import MetricCard from './components/overview/MetricCard.vue';
import { OVERVIEW_METRICS } from './constants';
import ReportHeatmap from './components/Heatmap.vue';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.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 { OVERVIEW_METRICS } from './constants';
const dayFilterOptions = [
{
id: 1,
name: 'Last 7 days',
},
{
id: 2,
name: 'Last 30 days',
},
];
export default {
name: 'LiveReports',
components: {
OpenConversations,
TeamTable,
AgentTable,
MetricCard,
ReportHeatmap,
MultiselectDropdown,
},
data() {
return {
pageIndex: 1,
dayFilterOptions: dayFilterOptions,
selectedDayFilter: dayFilterOptions[0],
};
},
computed: {
...mapGetters({
agentStatus: 'agents/getAgentStatus',
agents: 'agents/getAgents',
teams: 'teams/getTeams',
accountConversationMetric: 'getAccountConversationMetric',
agentConversationMetric: 'getAgentConversationMetric',
teamConversationMetric: 'getTeamConversationMetric',
accountConversationHeatmap: 'getAccountConversationHeatmapData',
uiFlags: 'getOverviewUIFlags',
}),
@@ -138,10 +113,19 @@ export default {
});
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.$store.dispatch('teams/get');
this.fetchAllData();
bus.$on('fetch_overview_reports', () => {
@@ -149,24 +133,18 @@ export default {
});
},
methods: {
onSelectDateFilter(dayFilter) {
this.selectedDayFilter = dayFilter;
this.fetchHeatmapData();
},
fetchAllData() {
this.$store.dispatch('fetchAgentConversationMetric');
this.$store.dispatch('fetchTeamConversationMetric');
this.fetchAccountConversationMetric();
this.fetchAgentConversationMetric();
this.fetchHeatmapData();
},
downloadHeatmapData() {
let to = endOfDay(new Date());
this.$store.dispatch('downloadAccountConversationHeatmap', {
daysBefore: this.selectedDayFilter?.id === 1 ? 6 : 29,
to: getUnixTime(to),
});
},
fetchHeatmapData() {
if (this.uiFlags.isFetchingAccountConversationsHeatmap) {
return;
@@ -179,9 +157,12 @@ export default {
// 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, this.selectedDayFilter?.id === 1 ? 6 : 29)
);
let from = startOfDay(subDays(to, 6));
if (this.accountConversationHeatmap.length) {
to = endOfDay(new Date());
from = startOfDay(to);
}
this.$store.dispatch('fetchAccountConversationHeatmap', {
metric: 'conversations_count',
@@ -191,6 +172,21 @@ export default {
businessHours: false,
});
},
fetchAccountConversationMetric() {
this.$store.dispatch('fetchAccountConversationMetric', {
type: 'account',
});
},
fetchAgentConversationMetric() {
this.$store.dispatch('fetchAgentConversationMetric', {
type: 'agent',
page: this.pageIndex,
});
},
onPageNumberChange(pageIndex) {
this.pageIndex = pageIndex;
this.fetchAgentConversationMetric();
},
},
};
</script>
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col flex-1 gap-6 px-4 pt-4 overflow-auto">
<div class="flex flex-col flex-1 px-4 pt-4 overflow-auto">
<SLAReportFilters @filter-change="onFilterChange" />
<woot-button
color-scheme="success"
@@ -44,15 +44,8 @@ export default {
data() {
return {
pageNumber: 1,
activeFilter: {
from: 0,
to: 0,
assigned_agent_id: null,
inbox_id: null,
team_id: null,
sla_policy_id: null,
label_list: null,
},
from: 0,
to: 0,
};
},
computed: {
@@ -64,11 +57,6 @@ export default {
}),
},
mounted() {
this.$store.dispatch('agents/get');
this.$store.dispatch('inboxes/get');
this.$store.dispatch('teams/get');
this.$store.dispatch('labels/get');
this.$store.dispatch('sla/get');
this.fetchSLAMetrics();
this.fetchSLAReports();
},
@@ -76,17 +64,22 @@ export default {
fetchSLAReports({ pageNumber } = {}) {
this.$store.dispatch('slaReports/get', {
page: pageNumber || this.pageNumber,
...this.activeFilter,
from: this.from,
to: this.to,
});
},
fetchSLAMetrics() {
this.$store.dispatch('slaReports/getMetrics', this.activeFilter);
this.$store.dispatch('slaReports/getMetrics', {
from: this.from,
to: this.to,
});
},
onPageChange(pageNumber) {
this.fetchSLAReports({ pageNumber });
},
onFilterChange(params) {
this.activeFilter = params;
onFilterChange({ from, to }) {
this.from = from;
this.to = to;
this.fetchSLAReports();
this.fetchSLAMetrics();
},
@@ -1,47 +1,19 @@
<template>
<div class="column overflow-auto">
<metric-card :is-live="false" header="All teams overview">
<woot-summary-reports
key="team-reports"
class="!p-0"
type="team"
getter-key="teams/getTeams"
attribute-key="team_id"
action-key="summaryReports/fetchTeamSummaryReports"
summary-key="summaryReports/getTeamSummaryReports"
:download-button-label="$t('TEAM_REPORTS.DOWNLOAD_TEAM_REPORTS')"
/>
</metric-card>
<metric-card
:is-live="false"
header="Team-wise reports"
class="overflow-visible"
>
<woot-reports
key="team-reports"
:show-download-button="false"
class="!p-0"
type="team"
getter-key="teams/getTeams"
action-key="teams/get"
/>
</metric-card>
</div>
<woot-reports
key="team-reports"
type="team"
getter-key="teams/getTeams"
action-key="teams/get"
:download-button-label="$t('TEAM_REPORTS.DOWNLOAD_TEAM_REPORTS')"
/>
</template>
<script>
import WootSummaryReports from './components/WootSummaryReports.vue';
import MetricCard from './components/overview/MetricCard.vue';
import WootReports from './components/WootReports.vue';
export default {
components: {
WootReports,
MetricCard,
WootSummaryReports,
},
mounted() {
this.$store.dispatch('agents/get');
},
};
</script>
@@ -1,141 +0,0 @@
<template>
<div class="agent-table-container">
<ve-table
max-height="calc(100vh - 21.875rem)"
:fixed-header="true"
:columns="columns"
:table-data="tableData"
/>
</div>
</template>
<script>
import { VeTable } from 'vue-easytable';
import rtlMixin from 'shared/mixins/rtlMixin';
import { mapGetters } from 'vuex';
export default {
name: 'TeamTable',
components: {
VeTable,
},
mixins: [rtlMixin],
computed: {
...mapGetters({
teams: 'teams/getTeams',
teamMetrics: 'summaryReports/getTeamSummaryReports',
}),
columns() {
return [
{
field: 'agent',
key: 'agent',
title: 'Team',
fixed: 'left',
align: this.isRTLView ? 'right' : 'left',
width: 25,
renderBodyCell: ({ row }) => (
<div class="row-user-block">
<div class="user-block">
<h6 class="title overflow-hidden whitespace-nowrap text-ellipsis">
{row.name}
</h6>
</div>
</div>
),
},
{
field: 'conversationsCount',
key: 'conversationsCount',
title: 'No. of conversations',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
{
field: 'resolutionsCount',
key: 'resolutionsCount',
title: 'No. of resolved conversations',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
{
field: 'avgFirstResponseTime',
key: 'avgFirstResponseTime',
title: 'Average first response time',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
{
field: 'avgResolutionTime',
key: 'avgResolutionTime',
title: 'Average resolution time',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
];
},
},
mounted() {
this.$store.dispatch('summaryReports/fetchTeamSummaryReports');
},
methods: {
getTeamMetrics(id) {
return (
this.teamMetrics.find(metrics => metrics.team_id === Number(id)) || {}
);
},
},
};
</script>
<style lang="scss" scoped>
.agent-table-container {
@apply flex flex-col flex-1;
.ve-table {
&::v-deep {
th.ve-table-header-th {
font-size: var(--font-size-mini) !important;
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>
@@ -1,74 +0,0 @@
<script setup>
import FilterButton from './FilterButton.vue';
import FilterListDropdown from './FilterListDropdown.vue';
const props = defineProps({
name: {
type: String,
required: true,
},
id: {
type: Number,
required: true,
},
type: {
type: String,
required: true,
},
options: {
type: Array,
default: () => [],
},
activeFilterType: {
type: String,
default: '',
},
showMenu: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
enableSearch: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'toggleDropdown',
'removeFilter',
'addFilter',
'closeDropdown',
]);
const toggleDropdown = () => emit('toggleDropdown', props.type);
const removeFilter = () => emit('removeFilter', props.type);
const addFilter = item => emit('addFilter', item);
const closeDropdown = () => emit('closeDropdown');
</script>
<template>
<filter-button
right-icon="chevron-down"
:button-text="name"
class="bg-slate-50 dark:bg-slate-800 hover:bg-slate-75 dark:hover:bg-slate-800"
@click="toggleDropdown"
>
<template v-if="showMenu && activeFilterType === type" #dropdown>
<filter-list-dropdown
v-if="options"
v-on-clickaway="closeDropdown"
show-clear-filter
:list-items="options"
:active-filter-id="id"
:input-placeholder="placeholder"
:enable-search="enableSearch"
class="flex flex-col w-[240px] overflow-y-auto left-0 md:left-auto md:right-0 top-10"
@click="addFilter"
@removeFilter="removeFilter"
/>
</template>
</filter-button>
</template>
@@ -1,98 +0,0 @@
<script setup>
import FilterButton from './FilterButton.vue';
import FilterListDropdown from './FilterListDropdown.vue';
import FilterListItemButton from './FilterListItemButton.vue';
import FilterDropdownEmptyState from './FilterDropdownEmptyState.vue';
import { ref } from 'vue';
defineProps({
name: {
type: String,
required: true,
},
menuOption: {
type: Array,
default: () => [],
},
showMenu: {
type: Boolean,
default: false,
},
placeholderI18nKey: {
type: String,
default: '',
},
enableSearch: {
type: Boolean,
default: true,
},
emptyStateMessage: {
type: String,
default: '',
},
});
const hoveredItemId = ref(null);
const showSubMenu = id => {
hoveredItemId.value = id;
};
const hideSubMenu = () => {
hoveredItemId.value = null;
};
const isHovered = id => hoveredItemId.value === id;
const emit = defineEmits(['toggleDropdown', 'addFilter', 'closeDropdown']);
const toggleDropdown = () => emit('toggleDropdown');
const addFilter = item => {
emit('addFilter', item);
hideSubMenu();
};
const closeDropdown = () => {
hideSubMenu();
emit('closeDropdown');
};
</script>
<template>
<filter-button :button-text="name" left-icon="filter" @click="toggleDropdown">
<!-- Dropdown with search and sub-dropdown -->
<template v-if="showMenu" #dropdown>
<filter-list-dropdown
v-on-clickaway="closeDropdown"
class="left-0 md:right-0 top-10"
>
<template #listItem>
<filter-dropdown-empty-state
v-if="!menuOption.length"
:message="emptyStateMessage"
/>
<filter-list-item-button
v-for="item in menuOption"
:key="item.id"
:button-text="item.name"
@mouseenter="showSubMenu(item.id)"
@mouseleave="hideSubMenu"
@focus="showSubMenu(item.id)"
>
<!-- Submenu with search and clear button -->
<template v-if="item.options && isHovered(item.id)" #dropdown>
<filter-list-dropdown
:list-items="item.options"
:input-placeholder="
$t(`${placeholderI18nKey}.${item.type.toUpperCase()}`)
"
:enable-search="enableSearch"
class="flex flex-col w-[216px] overflow-y-auto top-0 left-36"
@click="addFilter"
/>
</template>
</filter-list-item-button>
</template>
</filter-list-dropdown>
</template>
</filter-button>
</template>
@@ -4,44 +4,23 @@ defineProps({
type: String,
default: '',
},
rightIcon: {
type: String,
default: '',
},
leftIcon: {
type: String,
default: '',
},
});
</script>
<template>
<button
class="inline-flex relative items-center p-1.5 w-fit h-8 gap-1.5 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
class="inline-flex relative items-center p-1.5 w-fit h-8 gap-1.5 bg-white dark:bg-slate-900 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
@click="$emit('click')"
>
<slot name="leftIcon">
<fluent-icon
v-if="leftIcon"
:icon="leftIcon"
size="18"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/>
</slot>
<slot name="leftIcon" />
<span
v-if="buttonText"
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
class="text-sm font-medium text-slate-900 dark:text-slate-50"
>
{{ buttonText }}
</span>
<slot name="rightIcon">
<fluent-icon
v-if="rightIcon"
:icon="rightIcon"
size="18"
class="flex-shrink-0 text-slate-900 dark:text-slate-50"
/>
</slot>
<slot name="dropdown" />
<slot name="rightIcon" />
<div v-if="$slots.dropdown" class="absolute right-0 top-10" @click.stop>
<slot name="dropdown" />
</div>
</button>
</template>
@@ -1,5 +1,9 @@
<script setup>
defineProps({
buttonText: {
type: String,
default: '',
},
inputValue: {
type: String,
default: '',
@@ -8,17 +12,13 @@ defineProps({
type: String,
default: '',
},
showClearFilter: {
type: Boolean,
default: false,
},
});
</script>
<template>
<div
class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-white z-10 dark:bg-slate-800 gap-2 px-3 border-b rounded-t-xl border-slate-50 dark:border-slate-700"
>
<div class="flex items-center w-full gap-2">
<div class="flex items-center gap-2">
<fluent-icon
icon="search"
size="18"
@@ -32,16 +32,14 @@ defineProps({
@input="$emit('input', $event.target.value)"
/>
</div>
<!-- Clear filter button -->
<woot-button
v-if="!inputValue && showClearFilter"
size="small"
variant="clear"
color-scheme="primary"
class="!px-1 !py-1.5"
@click="$emit('click')"
>
{{ $t('REPORT.FILTER_ACTIONS.CLEAR_FILTER') }}
{{ buttonText }}
</woot-button>
</div>
</template>
@@ -14,17 +14,17 @@ const props = defineProps({
type: Boolean,
default: false,
},
inputPlaceholder: {
inputButtonText: {
type: String,
default: '',
},
activeFilterId: {
type: Number,
default: null,
emptyListMessage: {
type: String,
default: '',
},
showClearFilter: {
type: Boolean,
default: false,
inputPlaceholder: {
type: String,
default: '',
},
});
@@ -42,36 +42,29 @@ const filteredListItems = computed(() => {
const isDropdownListEmpty = computed(() => {
return !filteredListItems.value.length;
});
const isFilterActive = id => {
if (!props.activeFilterId) return false;
return id === props.activeFilterId;
};
</script>
<template>
<div
class="absolute z-20 w-40 bg-white border shadow dark:bg-slate-800 rounded-xl border-slate-50 dark:border-slate-700/50 max-h-[400px]"
@click.stop
class="z-20 w-40 bg-white border shadow dark:bg-slate-800 rounded-xl border-slate-50 dark:border-slate-700/50 max-h-72"
>
<slot name="search">
<filter-dropdown-search
v-if="enableSearch && listItems.length"
:button-text="inputButtonText"
:input-value="searchTerm"
:input-placeholder="inputPlaceholder"
:show-clear-filter="showClearFilter"
@input="onSearch"
@click="$emit('removeFilter')"
@click="onSearch('')"
/>
</slot>
<slot name="listItem">
<filter-dropdown-empty-state
v-if="isDropdownListEmpty"
:message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')"
:message="emptyListMessage"
/>
<filter-list-item-button
v-for="item in filteredListItems"
:key="item.id"
:is-active="isFilterActive(item.id)"
:button-text="item.name"
@click="$emit('click', item)"
/>
@@ -13,7 +13,7 @@ defineProps({
<template>
<button
class="relative inline-flex items-center justify-start w-full p-3 border-0 rounded-none first:rounded-t-xl last:rounded-b-xl h-11 hover:bg-slate-50 dark:hover:bg-slate-700 active:bg-slate-75 dark:active:bg-slate-800"
@click.stop="$emit('click')"
@click="$emit('click')"
@mouseenter="$emit('mouseenter')"
@mouseleave="$emit('mouseleave')"
@focus="$emit('focus')"
@@ -69,10 +69,6 @@ export default {
type: Array,
default: () => [],
},
selectedDayFilter: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
@@ -169,7 +165,6 @@ $marker-height: var(--space-two);
@mixin heatmap-level($level) {
$color: map-get($heatmap-colors, 'level-#{$level}');
background-color: $color;
&:hover {
border: 1px solid map-get($heatmap-hover-border-color, 'level-#{$level}');
}
@@ -193,7 +188,6 @@ $marker-height: var(--space-two);
100% {
opacity: 1;
}
50% {
opacity: 0;
}
@@ -276,23 +270,18 @@ $marker-height: var(--space-two);
&.l1 {
@include heatmap-level(1);
}
&.l2 {
@include heatmap-level(2);
}
&.l3 {
@include heatmap-level(3);
}
&.l4 {
@include heatmap-level(4);
}
&.l5 {
@include heatmap-level(5);
}
&.l6 {
@include heatmap-level(6);
}
@@ -1,85 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { OVERVIEW_METRICS } from '../../constants';
import MetricCard from '../overview/MetricCard.vue';
// import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
const noneTeam = { team_id: 0, name: 'All teams' };
export default {
components: {
// MultiselectDropdown,
MetricCard,
},
data() {
return {
selectedTeam: noneTeam,
};
},
computed: {
...mapGetters({
teams: 'teams/getTeams',
accountConversationMetric: 'getAccountConversationMetric',
uiFlags: 'getOverviewUIFlags',
}),
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;
},
teamsList() {
return [noneTeam, ...this.teams];
},
},
mounted() {
this.$store.dispatch('fetchLiveConversationMetric');
},
methods: {
onSelectTeam(team) {
this.$store.dispatch('fetchLiveConversationMetric', {
team_id: !team.id ? null : team.id,
});
this.selectedTeam = team;
},
},
};
</script>
<template>
<div class="column small-12 medium-8 conversation-metric">
<metric-card
class="overflow-visible min-h-[150px]"
:header="$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.HEADER')"
:is-loading="uiFlags.isFetchingAccountConversationMetric"
loading-message="Loading metrics"
>
<!-- <template #control>
<multiselect-dropdown
class="!mb-0 !w-1/2"
:options="teamsList"
:selected-item="selectedTeam"
multiselector-title=""
multiselector-placeholder="All teams"
no-search-result="No teams found"
input-placeholder="Search for a team"
:is-filter="true"
@click="onSelectTeam"
/>
</template> -->
<div
v-for="(metric, name, index) in conversationMetrics"
:key="index"
class="metric-content column"
>
<h3 class="heading">
{{ name }}
</h3>
<p class="metric">{{ metric }}</p>
</div>
</metric-card>
</div>
</template>
@@ -1,210 +0,0 @@
<template>
<div
class="flex flex-col flex-wrap items-start gap-2 md:items-center md:flex-nowrap md:flex-row"
>
<!-- Active filters section -->
<div v-if="hasActiveFilters" class="flex flex-wrap gap-2 md:flex-nowrap">
<active-filter-chip
v-for="filter in activeFilters"
v-bind="filter"
:key="filter.type"
:placeholder="
$t(
`SLA_REPORTS.DROPDOWN.INPUT_PLACEHOLDER.${filter.type.toUpperCase()}`
)
"
:active-filter-type="activeFilterType"
:show-menu="showSubDropdownMenu"
enable-search
@toggleDropdown="openActiveFilterDropdown"
@closeDropdown="closeActiveFilterDropdown"
@addFilter="addFilter"
@removeFilter="removeFilter"
/>
</div>
<!-- Dividing line between Active filters and Add filter button -->
<div
v-if="hasActiveFilters && !isAllFilterSelected"
class="w-full h-px border md:w-px md:h-5 border-slate-75 dark:border-slate-800"
/>
<!-- Add filter and clear filter button -->
<div class="flex items-center gap-2">
<add-filter-chip
v-if="!isAllFilterSelected"
placeholder-i18n-key="SLA_REPORTS.DROPDOWN.INPUT_PLACEHOLDER"
:name="$t('SLA_REPORTS.DROPDOWN.ADD_FIlTER')"
:menu-option="filterListMenuItems"
:show-menu="showDropdownMenu"
:empty-state-message="$t('SLA_REPORTS.DROPDOWN.NO_FILTER')"
@toggleDropdown="showDropdown"
@closeDropdown="closeDropdown"
@addFilter="addFilter"
/>
<!-- Dividing line between Add filter and Clear all filter button -->
<div
v-if="hasActiveFilters"
class="w-px h-5 border border-slate-75 dark:border-slate-800"
/>
<!-- Clear all filter button -->
<filter-button
v-if="hasActiveFilters"
:button-text="$t('SLA_REPORTS.DROPDOWN.CLEAR_ALL')"
@click="clearAllFilters"
/>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import {
buildFilterList,
getActiveFilter,
getFilterType,
} from './helpers/SLAFilterHelpers';
import FilterButton from '../Filters/v3/FilterButton.vue';
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
export default {
components: {
FilterButton,
ActiveFilterChip,
AddFilterChip,
},
data() {
return {
showDropdownMenu: false,
showSubDropdownMenu: false,
activeFilterType: '',
appliedFilters: {
assigned_agent_id: null,
inbox_id: null,
team_id: null,
sla_policy_id: null,
label_list: null,
},
};
},
computed: {
...mapGetters({
agents: 'agents/getAgents',
inboxes: 'inboxes/getInboxes',
teams: 'teams/getTeams',
labels: 'labels/getLabels',
sla: 'sla/getSLA',
}),
filterListMenuItems() {
const filterTypes = [
{ id: '1', name: this.$t('SLA_REPORTS.DROPDOWN.SLA'), type: 'sla' },
{
id: '2',
name: this.$t('SLA_REPORTS.DROPDOWN.INBOXES'),
type: 'inboxes',
},
{
id: '3',
name: this.$t('SLA_REPORTS.DROPDOWN.AGENTS'),
type: 'agents',
},
{ id: '4', name: this.$t('SLA_REPORTS.DROPDOWN.TEAMS'), type: 'teams' },
{
id: '5',
name: this.$t('SLA_REPORTS.DROPDOWN.LABELS'),
type: 'labels',
},
];
// Filter out the active filters from the filter list
// We only want to show the filters that are not already applied
// In the add filter dropdown
const activeFilters = Object.keys(this.appliedFilters).filter(
key => this.appliedFilters[key]
);
const activeFilterTypes = activeFilters.map(key =>
getFilterType(key, 'keyToType')
);
return filterTypes
.filter(({ type }) => !activeFilterTypes.includes(type))
.map(({ id, name, type }) => ({
id,
name,
type,
options: buildFilterList(this[type], type),
}));
},
activeFilters() {
// Get the active filters from the applied filters
// and return the filter name, type and options
const activeKey = Object.keys(this.appliedFilters).filter(
key => this.appliedFilters[key]
);
return activeKey.map(key => {
const filterType = getFilterType(key, 'keyToType');
const item = getActiveFilter(
this[filterType],
filterType,
this.appliedFilters[key]
);
return {
id: item.id,
name: filterType === 'labels' ? item.title : item.name,
type: filterType,
options: buildFilterList(this[filterType], filterType),
};
});
},
hasActiveFilters() {
return Object.values(this.appliedFilters).some(value => value !== null);
},
isAllFilterSelected() {
return !this.filterListMenuItems.length;
},
},
methods: {
addFilter(item) {
const { type, id, name } = item;
const filterKey = getFilterType(type, 'typeToKey');
this.appliedFilters[filterKey] = type === 'labels' ? name : id;
this.$emit('filter-change', this.appliedFilters);
this.resetDropdown();
},
removeFilter(type) {
const filterKey = getFilterType(type, 'typeToKey');
this.appliedFilters[filterKey] = null;
this.$emit('filter-change', this.appliedFilters);
},
clearAllFilters() {
this.appliedFilters = {
assigned_agent_id: null,
inbox_id: null,
team_id: null,
sla_policy_id: null,
label_list: null,
};
this.$emit('filter-change', this.appliedFilters);
this.resetDropdown();
},
showDropdown() {
this.showSubDropdownMenu = false;
this.showDropdownMenu = !this.showDropdownMenu;
},
closeDropdown() {
this.showDropdownMenu = false;
},
openActiveFilterDropdown(filterType) {
this.closeDropdown();
this.activeFilterType = filterType;
this.showSubDropdownMenu = !this.showSubDropdownMenu;
},
closeActiveFilterDropdown() {
this.activeFilterType = '';
this.showSubDropdownMenu = false;
},
resetDropdown() {
this.closeDropdown();
this.closeActiveFilterDropdown();
},
},
};
</script>
@@ -1,25 +1,22 @@
<template>
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
<reports-filters-date-range
class="sm:min-w-[200px] tiny h-8"
@on-range-change="onDateRangeChange"
/>
<woot-date-range-picker
v-if="isDateRangeSelected"
show-range
class="no-margin auto-width sm:min-w-[240px] small h-8"
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onCustomDateRangeChange"
/>
<SLA-filter @filter-change="emitFilterChange" />
<div class="flex flex-col md:flex-row justify-between mb-4">
<div class="md:grid flex flex-col filter-container gap-3 w-full">
<reports-filters-date-range @on-range-change="onDateRangeChange" />
<woot-date-range-picker
v-if="isDateRangeSelected"
show-range
class="no-margin auto-width"
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onCustomDateRangeChange"
/>
</div>
</div>
</template>
<script>
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
import ReportsFiltersDateRange from '../Filters/DateRange.vue';
import SLAFilter from '../SLA/SLAFilter.vue';
import subDays from 'date-fns/subDays';
import { DATE_RANGE_OPTIONS } from '../../constants';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
@@ -28,7 +25,6 @@ export default {
components: {
WootDateRangePicker,
ReportsFiltersDateRange,
SLAFilter,
},
data() {
@@ -74,13 +70,8 @@ export default {
this.$emit('filter-change', {
from,
to,
...this.selectedGroupByFilter,
});
},
emitFilterChange(params) {
this.selectedGroupByFilter = params;
this.emitChange();
},
onDateRangeChange(selectedRange) {
this.selectedDateRange = selectedRange;
this.emitChange();
@@ -92,3 +83,9 @@ export default {
},
};
</script>
<style scoped>
.filter-container {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
</style>
@@ -1,37 +0,0 @@
export const buildFilterList = (items, type) =>
// Build the filter list for the dropdown
items.map(item => ({
id: item.id,
name: type === 'labels' ? item.title : item.name,
type,
}));
export const getActiveFilter = (filters, type, key) => {
// Method is used to get the active filter from the filter list
return filters.find(filterItem =>
type === 'labels'
? filterItem.title === key
: filterItem.id.toString() === key.toString()
);
};
export const getFilterType = (input, direction) => {
// Method is used to map the filter key to the filter type
const filterMap = {
keyToType: {
assigned_agent_id: 'agents',
inbox_id: 'inboxes',
team_id: 'teams',
sla_policy_id: 'sla',
label_list: 'labels',
},
typeToKey: {
agents: 'assigned_agent_id',
inboxes: 'inbox_id',
teams: 'team_id',
sla: 'sla_policy_id',
labels: 'label_list',
},
};
return filterMap[direction][input];
};
@@ -1,7 +1,6 @@
<template>
<div class="flex-1 overflow-auto p-4">
<woot-button
v-if="showDownloadButton"
color-scheme="success"
class-names="button--fixed-top"
icon="arrow-download"
@@ -65,10 +64,6 @@ export default {
type: String,
default: 'Download Reports',
},
showDownloadButton: {
type: Boolean,
default: true,
},
},
data() {
return {
@@ -1,180 +0,0 @@
<template>
<div class="flex-1 overflow-auto p-4">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="arrow-download"
@click="downloadReports"
>
{{ downloadButtonLabel }}
</woot-button>
<report-filter-selector
:show-agents-filter="false"
:show-group-by-filter="false"
@filter-change="onFilterChange"
/>
<ve-table
max-height="calc(100vh - 21.875rem)"
:fixed-header="true"
:columns="columns"
:table-data="tableData"
/>
</div>
</template>
<script>
import ReportFilterSelector from './FilterSelector.vue';
import { formatTime } from '@chatwoot/utils';
import reportMixin from '../../../../../mixins/reportMixin';
import { generateFileName } from '../../../../../helper/downloadHelper';
import { VeTable } from 'vue-easytable';
export default {
components: {
VeTable,
ReportFilterSelector,
},
mixins: [reportMixin],
props: {
type: {
type: String,
default: 'account',
},
getterKey: {
type: String,
default: '',
},
actionKey: {
type: String,
default: '',
},
summaryKey: {
type: String,
default: '',
},
downloadButtonLabel: {
type: String,
default: 'Download Reports',
},
},
data() {
return {
from: 0,
to: 0,
selectedFilter: null,
businessHours: false,
};
},
computed: {
columns() {
return [
{
field: 'agent',
key: 'agent',
title: this.type,
fixed: 'left',
align: this.isRTLView ? 'right' : 'left',
width: 25,
renderBodyCell: ({ row }) => (
<div class="row-user-block">
<div class="user-block">
<h6 class="title overflow-hidden whitespace-nowrap text-ellipsis text-sm capitalize">
{row.name}
</h6>
</div>
</div>
),
},
{
field: 'conversationsCount',
key: 'conversationsCount',
title: 'Assigned',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
{
field: 'resolutionsCount',
key: 'resolutionsCount',
title: 'Resolved',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
{
field: 'avgFirstResponseTime',
key: 'avgFirstResponseTime',
title: 'Avg. first response time',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
{
field: 'avgResolutionTime',
key: 'avgResolutionTime',
title: 'Avg. resolution time',
align: this.isRTLView ? 'right' : 'left',
width: 20,
},
];
},
tableData() {
return this.filterItemsList.map(team => {
const typeMetrics = this.getMetrics(team.id);
return {
name: team.name,
conversationsCount: typeMetrics.conversations_count || '--',
avgFirstResponseTime:
this.renderContent(typeMetrics.avg_first_response_time) || '--',
avgResolutionTime:
this.renderContent(typeMetrics.avg_resolution_time) || '--',
resolutionsCount: typeMetrics.resolved_conversations_count || '--',
};
});
},
filterItemsList() {
return this.$store.getters[this.getterKey] || [];
},
typeMetrics() {
return this.$store.getters[this.summaryKey] || [];
},
},
mounted() {
this.fetchAllData();
},
methods: {
renderContent(value) {
return value ? formatTime(value) : '--';
},
getMetrics(id) {
return this.typeMetrics.find(metrics => metrics.id === Number(id)) || {};
},
fetchAllData() {
const { from, to, businessHours } = this;
this.$store.dispatch(this.actionKey, {
since: from,
until: to,
businessHours,
});
},
downloadReports() {
const { from, to, type, businessHours } = this;
const dispatchMethods = {
agent: 'downloadAgentReports',
label: 'downloadLabelReports',
inbox: 'downloadInboxReports',
team: 'downloadTeamReports',
};
if (dispatchMethods[type]) {
const fileName = generateFileName({ type, to, businessHours });
const params = { from, to, fileName, businessHours };
this.$store.dispatch(dispatchMethods[type], params);
}
},
onFilterChange({ from, to, businessHours }) {
this.from = from;
this.to = to;
this.businessHours = businessHours;
this.fetchAllData();
},
},
};
</script>
@@ -12,12 +12,17 @@
$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE')
}}</span>
</div>
<div v-if="agents.length > 0" class="table-pagination">
<empty-state
v-else-if="!isLoading && !agentMetrics.length"
:title="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.NO_AGENTS')"
/>
<div v-if="agentMetrics.length > 0" class="table-pagination">
<ve-pagination
:total="agents.length"
:page-index="pageIndex"
:page-size="25"
:page-size-option="[25]"
@on-page-number-change="onPageNumberChange"
/>
</div>
</div>
@@ -26,12 +31,14 @@
<script>
import { VeTable, VePagination } from 'vue-easytable';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
name: 'AgentTable',
components: {
EmptyState,
Spinner,
VeTable,
VePagination,
@@ -57,15 +64,15 @@ export default {
},
computed: {
tableData() {
return this.agents.map(agent => {
const agentMetrics = this.getAgentMetrics(agent.id);
return this.agentMetrics.map(agent => {
const agentInformation = this.getAgentInformation(agent.id);
return {
agent: agent.name,
email: agent.email,
thumbnail: agent.thumbnail,
open: agentMetrics.open || 0,
unattended: agentMetrics.unattended || 0,
status: agent.availability_status,
agent: agentInformation.name,
email: agentInformation.email,
thumbnail: agentInformation.thumbnail,
open: agent.metric.open || 0,
unattended: agent.metric.unattended || 0,
status: agentInformation.availability_status,
};
});
},
@@ -119,11 +126,11 @@ export default {
},
},
methods: {
getAgentMetrics(id) {
return (
this.agentMetrics.find(metrics => metrics.assignee_id === Number(id)) ||
{}
);
onPageNumberChange(pageIndex) {
this.$emit('page-change', pageIndex);
},
getAgentInformation(id) {
return this.agents.find(agent => agent.id === Number(id));
},
},
};
@@ -163,7 +170,6 @@ export default {
.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;
}
@@ -11,7 +11,6 @@
{{ header }}
</h5>
<span
v-if="isLive"
class="flex flex-row items-center pr-2 pl-2 m-1 rounded-sm text-green-400 dark:text-green-400 text-xs bg-green-100/30 dark:bg-green-100/20"
>
<span
@@ -29,7 +28,6 @@
</div>
<div
v-if="!isLoading"
:class="!isLive ? 'px-0' : ''"
class="card-body max-w-full w-full ml-auto mr-auto justify-between flex"
>
<slot />
@@ -54,10 +52,6 @@ export default {
Spinner,
},
props: {
isLive: {
type: Boolean,
default: true,
},
header: {
type: String,
default: '',
@@ -70,10 +64,6 @@ export default {
type: String,
default: '',
},
isFilter: {
type: Boolean,
default: false,
},
},
};
</script>
@@ -106,11 +96,9 @@ export default {
.card-body {
.metric-content {
@apply pb-2;
.heading {
@apply text-base text-slate-700 dark:text-slate-100;
}
.metric {
@apply text-woot-800 dark:text-woot-300 text-3xl mb-0 mt-1;
}
@@ -1,169 +0,0 @@
<template>
<div class="agent-table-container">
<ve-table
max-height="calc(100vh - 21.875rem)"
:fixed-header="true"
:columns="columns"
:table-data="tableData"
/>
<div v-if="isLoading" class="agents-loader">
<spinner />
<span>{{
$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.LOADING_MESSAGE')
}}</span>
</div>
<div v-if="teams.length > 0" class="table-pagination">
<ve-pagination
:total="teams.length"
:page-index="pageIndex"
:page-size="25"
:page-size-option="[25]"
/>
</div>
</div>
</template>
<script>
import { VeTable, VePagination } from 'vue-easytable';
import Spinner from 'shared/components/Spinner.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
export default {
name: 'TeamTable',
components: {
Spinner,
VeTable,
VePagination,
},
mixins: [rtlMixin],
props: {
teams: {
type: Array,
default: () => [],
},
teamMetrics: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
pageIndex: {
type: Number,
default: 1,
},
},
computed: {
tableData() {
return this.teams.map(team => {
const teamMetrics = this.getTeamMetrics(team.id);
return {
agent: team.name,
open: teamMetrics.open || 0,
unattended: teamMetrics.unattended || 0,
};
});
},
columns() {
return [
{
field: 'agent',
key: 'agent',
title: this.$t(
'OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.AGENT'
),
fixed: 'left',
align: this.isRTLView ? 'right' : 'left',
width: 25,
renderBodyCell: ({ row }) => (
<div class="row-user-block">
<div class="user-block">
<h6 class="capitalize title overflow-hidden whitespace-nowrap text-ellipsis">
{row.agent}
</h6>
</div>
</div>
),
},
{
field: 'open',
key: 'open',
title: this.$t(
'OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.OPEN'
),
align: this.isRTLView ? 'right' : 'left',
width: 10,
},
{
field: 'unattended',
key: 'unattended',
title: this.$t(
'OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.UNATTENDED'
),
align: this.isRTLView ? 'right' : 'left',
width: 10,
},
];
},
},
methods: {
getTeamMetrics(id) {
return (
this.teamMetrics.find(metrics => metrics.team_id === Number(id)) || {}
);
},
},
};
</script>
<style lang="scss" scoped>
.agent-table-container {
@apply flex flex-col flex-1;
.ve-table {
&::v-deep {
th.ve-table-header-th {
font-size: var(--font-size-mini) !important;
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>
@@ -18,6 +18,7 @@ import reports from './reports/reports.routes';
import store from '../../../store';
import sla from './sla/sla.routes';
import teams from './teams/teams.routes';
import personal from './personal/personal.routes';
export default {
routes: [
@@ -39,6 +40,7 @@ export default {
...automation.routes,
...auditlogs.routes,
...billing.routes,
...personal.routes,
...campaigns.routes,
...canned.routes,
...inbox.routes,
-2
View File
@@ -42,7 +42,6 @@ import sla from './modules/sla';
import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
import summaryReports from './modules/summaryReports';
import webhooks from './modules/webhooks';
import draftMessages from './modules/draftMessages';
import SLAReports from './modules/SLAReports';
@@ -109,7 +108,6 @@ export default new Vuex.Store({
reports,
teamMembers,
teams,
summaryReports,
userNotificationSettings,
webhooks,
draftMessages,
@@ -4,8 +4,10 @@ import Report from '../../api/reports';
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper';
import liveReports from '../../api/liveReports';
import {
reconcileHeatmapData,
clampDataBetweenTimeline,
} from 'shared/helpers/ReportsDataHelper';
const state = {
fetchingStatus: false,
@@ -55,12 +57,10 @@ const state = {
isFetchingAccountConversationMetric: false,
isFetchingAccountConversationsHeatmap: false,
isFetchingAgentConversationMetric: false,
isFetchingTeamConversationMetric: false,
},
accountConversationMetric: {},
accountConversationHeatmap: [],
agentConversationMetric: [],
teamConversationMetric: [],
},
};
@@ -83,9 +83,6 @@ const getters = {
getAgentConversationMetric(_state) {
return _state.overview.agentConversationMetric;
},
getTeamConversationMetric(_state) {
return _state.overview.teamConversationMetric;
},
getOverviewUIFlags($state) {
return $state.overview.uiFlags;
},
@@ -117,6 +114,11 @@ export const actions = {
let { data } = heatmapData;
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
data = reconcileHeatmapData(
data,
state.overview.accountConversationHeatmap
);
commit(types.default.SET_HEATMAP_DATA, data);
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
});
@@ -151,10 +153,9 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
});
},
fetchLiveConversationMetric({ commit }, params = {}) {
fetchAccountConversationMetric({ commit }, reportObj) {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
liveReports
.getConversationMetric(params)
Report.getConversationMetric(reportObj.type)
.then(accountConversationMetric => {
commit(
types.default.SET_ACCOUNT_CONVERSATION_METRIC,
@@ -166,10 +167,9 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
});
},
fetchAgentConversationMetric({ commit }) {
fetchAgentConversationMetric({ commit }, reportObj) {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true);
liveReports
.getGroupedConversations()
Report.getConversationMetric(reportObj.type, reportObj.page)
.then(agentConversationMetric => {
commit(
types.default.SET_AGENT_CONVERSATION_METRIC,
@@ -181,21 +181,6 @@ export const actions = {
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(agentConversationMetric => {
commit(
types.default.SET_TEAM_CONVERSATION_METRIC,
agentConversationMetric.data
);
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
})
.catch(() => {
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
});
},
downloadAgentReports(_, reportObj) {
return Report.getAgentReports(reportObj)
.then(response => {
@@ -249,7 +234,7 @@ export const actions = {
});
},
downloadAccountConversationHeatmap(_, reportObj) {
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
Report.getConversationTrafficCSV()
.then(response => {
downloadCsvFile(
generateFileName({
@@ -298,15 +283,9 @@ const mutations = {
[types.default.SET_AGENT_CONVERSATION_METRIC](_state, metricData) {
_state.overview.agentConversationMetric = metricData;
},
[types.default.SET_TEAM_CONVERSATION_METRIC](_state, metricData) {
_state.overview.teamConversationMetric = metricData;
},
[types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAgentConversationMetric = flag;
},
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingTeamConversationMetric = flag;
},
};
export default {
@@ -1,68 +0,0 @@
import SummaryReportsAPI from '../../api/summaryReports';
import Vue from 'vue';
export const state = {
teamSummaryReports: [],
agentSummaryReports: [],
inboxSummaryReports: [],
uiFlags: {},
};
export const getters = {
getAgentSummaryReports(_state) {
return _state.agentSummaryReports;
},
getTeamSummaryReports(_state) {
return _state.teamSummaryReports;
},
getInboxSummaryReports(_state) {
return _state.inboxSummaryReports;
},
};
export const actions = {
async fetchTeamSummaryReports({ commit }, params) {
try {
const response = await SummaryReportsAPI.getTeamReports(params);
commit('setTeamSummaryReport', response.data);
} catch (error) {
// Ignore error
}
},
async fetchAgentSummaryReports({ commit }, params) {
try {
const response = await SummaryReportsAPI.getAgentReports(params);
commit('setAgentSummaryReport', response.data);
} catch (error) {
// Ignore error
}
},
async fetchInboxSummaryReports({ commit }, params) {
try {
const response = await SummaryReportsAPI.getInboxReports(params);
commit('setInboxSummaryReport', response.data);
} catch (error) {
// Ignore error
}
},
};
export const mutations = {
setTeamSummaryReport(_state, data) {
Vue.set(_state, 'teamSummaryReports', data);
},
setAgentSummaryReport(_state, data) {
Vue.set(_state, 'agentSummaryReports', data);
},
setInboxSummaryReport(_state, data) {
Vue.set(_state, 'inboxSummaryReports', data);
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};
@@ -172,11 +172,8 @@ export default {
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING',
SET_AGENT_CONVERSATION_METRIC: 'SET_AGENT_CONVERSATION_METRIC',
SET_TEAM_CONVERSATION_METRIC: 'SET_TEAM_CONVERSATION_METRIC',
TOGGLE_AGENT_CONVERSATION_METRIC_LOADING:
'TOGGLE_AGENT_CONVERSATION_METRIC_LOADING',
TOGGLE_TEAM_CONVERSATION_METRIC_LOADING:
'TOGGLE_TEAM_CONVERSATION_METRIC_LOADING',
// Conversation Metadata
SET_CONVERSATION_METADATA: 'SET_CONVERSATION_METADATA',
-2
View File
@@ -31,7 +31,6 @@ import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
import AnalyticsPlugin from '../dashboard/helper/AnalyticsHelper/plugin';
import resizeDirective from '../dashboard/helper/directives/resize.js';
import { directive as onClickaway } from 'vue-clickaway';
Vue.config.env = process.env;
@@ -81,7 +80,6 @@ Vue.component('woot-wizard', WootWizard);
Vue.component('fluent-icon', FluentIcon);
Vue.directive('resize', resizeDirective);
Vue.directive('on-clickaway', onClickaway);
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
@@ -272,5 +272,6 @@
"chevrons-left-outline": ["m11 17-5-5 5-5", "m18 17-5-5 5-5"],
"chevron-left-single-outline": "m15 18-6-6 6-6",
"chevrons-right-outline": ["m6 17 5-5-5-5", "m13 17 5-5-5-5"],
"chevron-right-single-outline": "m9 18 6-6-6-6"
"chevron-right-single-outline": "m9 18 6-6-6-6",
"avatar-upload-outline": "M19.754 11a.75.75 0 0 1 .743.648l.007.102v7a3.25 3.25 0 0 1-3.065 3.246l-.185.005h-11a3.25 3.25 0 0 1-3.244-3.066l-.006-.184V11.75a.75.75 0 0 1 1.494-.102l.006.102v7a1.75 1.75 0 0 0 1.607 1.745l.143.006h11A1.75 1.75 0 0 0 19 18.894l.005-.143V11.75a.75.75 0 0 1 .75-.75ZM6.22 7.216l4.996-4.996a.75.75 0 0 1 .976-.073l.084.072l5.005 4.997a.75.75 0 0 1-.976 1.134l-.084-.073l-3.723-3.716l.001 11.694a.75.75 0 0 1-.648.743l-.102.007a.75.75 0 0 1-.743-.648L11 16.255V4.558L7.28 8.277a.75.75 0 0 1-.976.073l-.084-.073a.75.75 0 0 1-.073-.977l.073-.084l4.996-4.996L6.22 7.216Z"
}
+3 -2
View File
@@ -38,9 +38,10 @@ const setUserLastSeenAt = async ({ lastSeen }) => {
{ contact_last_seen_at: lastSeen }
);
};
const sendEmailTranscript = async () => {
const sendEmailTranscript = async ({ email }) => {
return API.post(
`/api/v1/widget/conversations/transcript${window.location.search}`
`/api/v1/widget/conversations/transcript${window.location.search}`,
{ email }
);
};
const toggleStatus = async () => {
@@ -87,10 +87,7 @@ export default {
return !allowMessagesAfterResolved && status === 'resolved';
},
showEmailTranscriptButton() {
return this.hasEmail;
},
hasEmail() {
return this.currentUser && this.currentUser.has_email;
return this.currentUser && this.currentUser.email;
},
hasReplyTo() {
return (
@@ -144,9 +141,12 @@ export default {
this.inReplyTo = message;
},
async sendTranscript() {
if (this.hasEmail) {
const { email } = this.currentUser;
if (email) {
try {
await sendEmailTranscript();
await sendEmailTranscript({
email,
});
window.bus.$emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
type: 'success',
@@ -1,7 +1,7 @@
<template>
<FormulateForm
v-model="formValues"
class="flex flex-col flex-1 p-6 overflow-y-auto"
class="flex flex-1 flex-col p-6 overflow-y-auto"
@submit="onSubmit"
>
<div
@@ -49,7 +49,7 @@
/>
<custom-button
class="mt-2 mb-5 font-medium"
class="font-medium mt-2 mb-5"
block
:bg-color="widgetColor"
:text-color="textColor"
@@ -133,10 +133,9 @@ export default {
return this.preChatFormEnabled ? this.options.preChatFields : [];
},
filteredPreChatFields() {
const isUserEmailAvailable = this.currentUser.has_email;
const isUserPhoneNumberAvailable = this.currentUser.has_phone_number;
const isUserEmailAvailable = !!this.currentUser.email;
const isUserPhoneNumberAvailable = !!this.currentUser.phone_number;
const isUserIdentifierAvailable = !!this.currentUser.identifier;
const isUserNameAvailable = !!(
isUserIdentifierAvailable ||
isUserEmailAvailable ||
@@ -303,10 +302,11 @@ export default {
},
onSubmit() {
const { emailAddress, fullName, phoneNumber, message } = this.formValues;
const { email } = this.currentUser;
this.$emit('submit', {
fullName,
phoneNumber,
emailAddress,
emailAddress: emailAddress || email,
message,
activeCampaignId: this.activeCampaign.id,
conversationCustomAttributes: this.conversationCustomAttributes,
@@ -3,8 +3,8 @@ import { getters } from '../../contacts';
describe('#getters', () => {
it('getCurrentUser', () => {
const user = {
has_email: true,
has_name: true,
email: 'thoma@sphadikam.com',
name: 'Adu Thoma',
avatar_url: '',
identifier_hash: 'malana_hash',
};
@@ -12,8 +12,8 @@ describe('#getters', () => {
currentUser: user,
};
expect(getters.getCurrentUser(state)).toEqual({
has_email: true,
has_name: true,
email: 'thoma@sphadikam.com',
name: 'Adu Thoma',
avatar_url: '',
identifier_hash: 'malana_hash',
});
@@ -4,8 +4,8 @@ describe('#mutations', () => {
describe('#SET_CURRENT_USER', () => {
it('set current user', () => {
const user = {
has_email: true,
has_name: true,
email: 'thoma@sphadikam.com',
name: 'Adu Thoma',
avatar_url: '',
identifier_hash: 'malana_hash',
};
-1
View File
@@ -170,4 +170,3 @@ class Article < ApplicationRecord
self.slug ||= "#{Time.now.utc.to_i}-#{title.underscore.parameterize(separator: '-')}" if title.present?
end
end
Article.include_mod_with('Concerns::Article')
-1
View File
@@ -41,7 +41,6 @@ class Channel::WebWidget < ApplicationRecord
before_validation :validate_pre_chat_options
validates :website_url, presence: true
validates :widget_color, presence: true
has_many :portals, foreign_key: 'channel_web_widget_id', dependent: :nullify, inverse_of: :channel_web_widget
has_secure_token :website_token
has_secure_token :hmac_token
+16 -19
View File
@@ -2,26 +2,24 @@
#
# Table name: portals
#
# id :bigint not null, primary key
# archived :boolean default(FALSE)
# color :string
# config :jsonb
# custom_domain :string
# header_text :text
# homepage_link :string
# name :string not null
# page_title :string
# slug :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# channel_web_widget_id :bigint
# id :bigint not null, primary key
# archived :boolean default(FALSE)
# color :string
# config :jsonb
# custom_domain :string
# header_text :text
# homepage_link :string
# name :string not null
# page_title :string
# slug :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_portals_on_channel_web_widget_id (channel_web_widget_id)
# index_portals_on_custom_domain (custom_domain) UNIQUE
# index_portals_on_slug (slug) UNIQUE
# index_portals_on_custom_domain (custom_domain) UNIQUE
# index_portals_on_slug (slug) UNIQUE
#
class Portal < ApplicationRecord
include Rails.application.routes.url_helpers
@@ -40,7 +38,6 @@ class Portal < ApplicationRecord
source: :user
has_one_attached :logo
has_many :inboxes, dependent: :nullify
belongs_to :channel_web_widget, class_name: 'Channel::WebWidget', optional: true
before_validation -> { normalize_empty_string_to_nil(%i[custom_domain homepage_link]) }
validates :account_id, presence: true
@@ -53,7 +50,7 @@ class Portal < ApplicationRecord
scope :active, -> { where(archived: false) }
CONFIG_JSON_KEYS = %w[allowed_locales default_locale website_token].freeze
CONFIG_JSON_KEYS = %w[allowed_locales default_locale].freeze
def file_base_data
{
@@ -1,5 +1,5 @@
json.id @contact.id
json.has_email @contact.email.present?
json.has_name @contact.name.present?
json.has_phone_number @contact.phone_number.present?
json.name @contact.name
json.email @contact.email
json.phone_number @contact.phone_number
json.widget_auth_token @widget_auth_token if @widget_auth_token.present?
@@ -1,5 +1,5 @@
json.id @contact.id
json.has_email @contact.email.present?
json.has_name @contact.name.present?
json.has_phone_number @contact.phone_number.present?
json.name @contact.name
json.email @contact.email
json.phone_number @contact.phone_number
json.identifier @contact.identifier
@@ -1,4 +1,4 @@
json.id @contact.id
json.has_email @contact.email.present?
json.has_name @contact.name.present?
json.has_phone_number @contact.phone_number.present?
json.name @contact.name
json.email @contact.email
json.phone_number @contact.phone_number
@@ -9,5 +9,5 @@
%>
<%= CSVSafe.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSV.generate_line row -%>
<%= CSVSafe.generate_line row -%>
<% end %>
@@ -1,5 +1,5 @@
<%= CSV.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
<% @report_data.each do |row| %>
<%= CSV.generate_line row -%>
<%= CSVSafe.generate_line row -%>
<% end %>
@@ -10,5 +10,5 @@
%>
<%= CSVSafe.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSV.generate_line row -%>
<%= CSVSafe.generate_line row -%>
<% end %>
@@ -9,5 +9,5 @@
%>
<%= CSVSafe.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSV.generate_line row -%>
<%= CSVSafe.generate_line row -%>
<% end %>
@@ -9,5 +9,5 @@
%>
<%= CSVSafe.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSV.generate_line row -%>
<%= CSVSafe.generate_line row -%>
<% end %>
+3 -6
View File
@@ -38,7 +38,7 @@ By default, it renders:
<% else %>
<title><%= @portal.page_title%></title>
<% end %>
<% unless @theme_from_params.blank? %>
<%# this adds the theme from params, ensuring that there a localstorage value set %>
<%# this will further trigger the next script to ensure color mode is toggled without a FOUC %>
@@ -75,13 +75,13 @@ By default, it renders:
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('dark') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'dark') %>;
}
html.light {
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'light') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('light') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'light') %>;
}
/* Portal background */
#portal-bg {
background: var(--dynamic-portal-bg);
@@ -128,7 +128,4 @@ By default, it renders:
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
};
</script>
<% if @portal.channel_web_widget.present? %>
<%= @portal.channel_web_widget.web_widget_script.html_safe %>
<% end %>
</html>
-3
View File
@@ -80,6 +80,3 @@
- name: sla
enabled: false
premium: true
- name: help_center_embedding_search
enabled: false
premium: true
@@ -34,4 +34,3 @@ ActiveRecord::SchemaDumper.ignore_tables << 'responses'
ActiveRecord::SchemaDumper.ignore_tables << 'response_sources'
ActiveRecord::SchemaDumper.ignore_tables << 'response_documents'
ActiveRecord::SchemaDumper.ignore_tables << 'inbox_response_sources'
ActiveRecord::SchemaDumper.ignore_tables << 'article_embeddings'
-7
View File
@@ -300,17 +300,10 @@ Rails.application.routes.draw do
namespace :v2 do
resources :accounts, only: [:create] do
scope module: :accounts do
resources :live_reports, only: [] do
collection do
get :conversation_metrics
get :grouped_conversation_metrics
end
end
resources :summary_reports, only: [] do
collection do
get :agent
get :team
get :inbox
end
end
resources :reports, only: [:index] do
@@ -1,6 +0,0 @@
class AddChannelWebWidgetToPortals < ActiveRecord::Migration[7.0]
def change
add_column :portals, :channel_web_widget_id, :bigint
add_index :portals, :channel_web_widget_id
end
end
+1 -3
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_04_15_210313) do
ActiveRecord::Schema[7.0].define(version: 2024_03_22_071629) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -800,8 +800,6 @@ ActiveRecord::Schema[7.0].define(version: 2024_04_15_210313) do
t.datetime "updated_at", null: false
t.jsonb "config", default: {"allowed_locales"=>["en"]}
t.boolean "archived", default: false
t.bigint "channel_web_widget_id"
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id"
t.index ["custom_domain"], name: "index_portals_on_custom_domain", unique: true
t.index ["slug"], name: "index_portals_on_slug", unique: true
end
@@ -1,11 +0,0 @@
module Enterprise::Public::Api::V1::Portals::ArticlesController
private
def search_articles
if @portal.account.feature_enabled?('help_center_embedding_search')
@articles = @articles.vector_search(list_params) if list_params.present?
else
super
end
end
end
+6 -6
View File
@@ -30,14 +30,14 @@ class AppliedSla < ApplicationRecord
enum sla_status: { active: 0, hit: 1, missed: 2, active_with_misses: 3 }
scope :filter_by_date_range, ->(range) { where(created_at: range) if range.present? }
scope :filter_by_inbox_id, ->(inbox_id) { joins(:conversation).where(conversations: { inbox_id: inbox_id }) if inbox_id.present? }
scope :filter_by_team_id, ->(team_id) { joins(:conversation).where(conversations: { team_id: team_id }) if team_id.present? }
scope :filter_by_inbox_id, ->(inbox_id) { where(inbox_id: inbox_id) if inbox_id.present? }
scope :filter_by_team_id, ->(team_id) { where(team_id: team_id) if team_id.present? }
scope :filter_by_sla_policy_id, ->(sla_policy_id) { where(sla_policy_id: sla_policy_id) if sla_policy_id.present? }
scope :filter_by_label_list, lambda { |label_list|
joins(:conversation).where('conversations.cached_label_list LIKE ?', "%#{label_list}%") if label_list.present?
}
scope :filter_by_label_list, ->(label_list) { joins(:conversation).where(conversations: { cached_label_list: label_list }) if label_list.present? }
scope :filter_by_assigned_agent_id, lambda { |assigned_agent_id|
joins(:conversation).where(conversations: { assignee_id: assigned_agent_id }) if assigned_agent_id.present?
if assigned_agent_id.present?
joins(:conversation).where(conversations: { assigned_agent_id: assigned_agent_id })
end
}
scope :missed, -> { where(sla_status: %i[missed active_with_misses]) }
@@ -1,23 +0,0 @@
# == Schema Information
#
# Table name: article_embeddings
#
# id :bigint not null, primary key
# embedding :vector(1536)
# term :text not null
# created_at :datetime not null
# updated_at :datetime not null
# article_id :bigint not null
#
class ArticleEmbedding < ApplicationRecord
belongs_to :article
has_neighbors :embedding, normalize: true
before_save :update_response_embedding
private
def update_response_embedding
self.embedding = Openai::EmbeddingsService.new.get_embedding(term, 'text-embedding-3-small')
end
end
@@ -1,71 +0,0 @@
module Enterprise::Concerns::Article
extend ActiveSupport::Concern
included do
after_save :add_article_embedding, if: -> { saved_change_to_title? || saved_change_to_description? || saved_change_to_content? }
def self.add_article_embedding_association
has_many :article_embeddings, dependent: :destroy_async
end
add_article_embedding_association if Features::HelpcenterEmbeddingSearchService.new.feature_enabled?
def self.vector_search(params)
embedding = Openai::EmbeddingsService.new.get_embedding(params['query'], 'text-embedding-3-small')
records = joins(
:category
).search_by_category_slug(
params[:category_slug]
).search_by_category_locale(params[:locale]).search_by_author(params[:author_id]).search_by_status(params[:status])
filtered_article_ids = records.pluck(:id)
# Fetch nearest neighbors and their distances, then filter directly
# experimenting with filtering results based on result threshold
# distance_threshold = 0.2
# if using add the filter block to the below query
# .filter { |ae| ae.neighbor_distance <= distance_threshold }
article_ids = ArticleEmbedding.where(article_id: filtered_article_ids)
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
.limit(5)
.pluck(:article_id)
# Fetch the articles by the IDs obtained from the nearest neighbors search
where(id: article_ids)
end
end
def add_article_embedding
return unless account.feature_enabled?('help_center_embedding_search')
terms = generate_article_search_terms
article_embeddings.destroy_all
terms.each { |term| article_embeddings.create!(term: term) }
end
def article_to_search_terms_prompt
<<~SYSTEM_PROMPT_MESSAGE
For the provided article content, generate potential search query keywords and snippets that can be used to generate the embeddings.
Ensure the search terms are as diverse as possible but capture the essence of the article and are super related to the articles.
Don't return any terms if there aren't any terms of relevance.
Always return results in valid JSON of the following format
{
"search_terms": []
}
SYSTEM_PROMPT_MESSAGE
end
def generate_article_search_terms
messages = [
{ role: 'system', content: article_to_search_terms_prompt },
{ role: 'user', content: "title: #{title} \n description: #{description} \n content: #{content}" }
]
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', nil)}" }
body = { model: 'gpt-4-turbo', messages: messages, response_format: { type: 'json_object' } }.to_json
Rails.logger.info "Requesting Chat GPT with body: #{body}"
response = HTTParty.post('https://api.openai.com/v1/chat/completions', headers: headers, body: body)
Rails.logger.info "Chat GPT response: #{response.body}"
JSON.parse(response.parsed_response['choices'][0]['message']['content'])['search_terms']
end
end
@@ -1,7 +0,0 @@
class Features::BaseService
MIGRATION_VERSION = ActiveRecord::Migration[7.0]
def vector_extension_enabled?
ActiveRecord::Base.connection.extension_enabled?('vector')
end
end
@@ -1,42 +0,0 @@
# ensure vector extension is enabled via response bot service
class Features::HelpcenterEmbeddingSearchService < Features::BaseService
def enable_in_installation
create_tables
end
def disable_in_installation
drop_tables
end
def feature_enabled?
vector_extension_enabled? && MIGRATION_VERSION.table_exists?(:article_embeddings)
end
def create_tables
return unless vector_extension_enabled?
%i[article_embeddings].each do |table|
send("create_#{table}_table")
end
end
def drop_tables
%i[article_embeddings].each do |table|
MIGRATION_VERSION.drop_table table if MIGRATION_VERSION.table_exists?(table)
end
end
private
def create_article_embeddings_table
return if MIGRATION_VERSION.table_exists?(:article_embeddings)
MIGRATION_VERSION.create_table :article_embeddings do |t|
t.bigint :article_id, null: false
t.text :term, null: false
t.vector :embedding, limit: 1536
t.timestamps
end
MIGRATION_VERSION.add_index :article_embeddingsk, :embedding, using: :ivfflat, opclass: :vector_l2_ops
end
end
@@ -1,4 +1,6 @@
class Features::ResponseBotService < Features::BaseService
class Features::ResponseBotService
MIGRATION_VERSION = ActiveRecord::Migration[7.0]
def enable_in_installation
enable_vector_extension
create_tables
@@ -19,6 +21,10 @@ class Features::ResponseBotService < Features::BaseService
MIGRATION_VERSION.disable_extension 'vector'
end
def vector_extension_enabled?
ActiveRecord::Base.connection.extension_enabled?('vector')
end
def create_tables
return unless vector_extension_enabled?
@@ -1,11 +1,11 @@
class Openai::EmbeddingsService
def get_embedding(content, model = 'text-embedding-ada-002')
fetch_embeddings(content, model)
def get_embedding(content)
fetch_embeddings(content)
end
private
def fetch_embeddings(input, model)
def fetch_embeddings(input)
url = 'https://api.openai.com/v1/embeddings'
headers = {
'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', '')}",
@@ -13,7 +13,7 @@ class Openai::EmbeddingsService
}
data = {
input: input,
model: model
model: 'text-embedding-ada-002'
}
response = Net::HTTP.post(URI(url), data.to_json, headers)
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -0,0 +1,25 @@
<svg width="318" height="100" viewBox="0 0 318 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="318" height="100" rx="8" fill="url(#paint0_linear_487_6940)"/>
<g filter="url(#filter0_d_487_6940)">
<rect x="223" y="56" width="79" height="28" rx="6" fill="#456EFF"/>
<path d="M236.142 74V65.2727H241.614V66.4062H237.459V69.0653H241.328V70.1946H237.459V72.8665H241.665V74H236.142ZM244.545 70.1136V74H243.271V67.4545H244.494V68.5199H244.575C244.725 68.1733 244.961 67.8949 245.282 67.6847C245.606 67.4744 246.013 67.3693 246.505 67.3693C246.951 67.3693 247.342 67.4631 247.677 67.6506C248.012 67.8352 248.272 68.1108 248.457 68.4773C248.641 68.8437 248.734 69.2969 248.734 69.8366V74H247.46V69.9901C247.46 69.5156 247.336 69.1449 247.089 68.8778C246.842 68.608 246.502 68.473 246.07 68.473C245.775 68.473 245.512 68.5369 245.282 68.6648C245.055 68.7926 244.874 68.9801 244.741 69.2273C244.61 69.4716 244.545 69.767 244.545 70.1136ZM253.498 67.4545V68.4773H249.923V67.4545H253.498ZM250.881 65.8864H252.156V72.0781C252.156 72.3253 252.192 72.5114 252.266 72.6364C252.34 72.7585 252.435 72.8423 252.552 72.8878C252.671 72.9304 252.8 72.9517 252.94 72.9517C253.042 72.9517 253.131 72.9446 253.208 72.9304C253.285 72.9162 253.344 72.9048 253.387 72.8963L253.617 73.9489C253.543 73.9773 253.438 74.0057 253.302 74.0341C253.165 74.0653 252.995 74.0824 252.79 74.0852C252.455 74.0909 252.143 74.0312 251.853 73.9062C251.563 73.7812 251.329 73.5881 251.15 73.3267C250.971 73.0653 250.881 72.7372 250.881 72.3423V65.8864ZM257.658 74.1321C257.013 74.1321 256.458 73.9943 255.992 73.7188C255.529 73.4403 255.171 73.0497 254.918 72.5469C254.668 72.0412 254.543 71.4489 254.543 70.7699C254.543 70.0994 254.668 69.5085 254.918 68.9972C255.171 68.4858 255.523 68.0866 255.975 67.7997C256.429 67.5128 256.961 67.3693 257.569 67.3693C257.938 67.3693 258.296 67.4304 258.642 67.5526C258.989 67.6747 259.3 67.8665 259.576 68.1278C259.851 68.3892 260.069 68.7287 260.228 69.1463C260.387 69.5611 260.466 70.0653 260.466 70.6591V71.1108H255.263V70.1562H259.218C259.218 69.821 259.15 69.5241 259.013 69.2656C258.877 69.0043 258.685 68.7983 258.438 68.6477C258.194 68.4972 257.907 68.4219 257.577 68.4219C257.219 68.4219 256.907 68.5099 256.64 68.6861C256.375 68.8594 256.171 69.0866 256.026 69.3679C255.884 69.6463 255.813 69.9489 255.813 70.2756V71.0213C255.813 71.4588 255.89 71.831 256.043 72.1378C256.199 72.4446 256.417 72.679 256.695 72.8409C256.973 73 257.299 73.0795 257.671 73.0795C257.912 73.0795 258.132 73.0455 258.331 72.9773C258.53 72.9062 258.702 72.8011 258.847 72.6619C258.992 72.5227 259.103 72.3509 259.179 72.1463L260.385 72.3636C260.289 72.7187 260.115 73.0298 259.865 73.2969C259.618 73.5611 259.307 73.767 258.932 73.9148C258.56 74.0597 258.135 74.1321 257.658 74.1321ZM261.88 74V67.4545H263.112V68.4943H263.18C263.299 68.142 263.509 67.8651 263.81 67.6634C264.114 67.4588 264.458 67.3565 264.842 67.3565C264.921 67.3565 265.015 67.3594 265.123 67.3651C265.234 67.3707 265.32 67.3778 265.383 67.3864V68.6051C265.332 68.5909 265.241 68.5753 265.11 68.5582C264.979 68.5384 264.849 68.5284 264.718 68.5284C264.417 68.5284 264.148 68.5923 263.913 68.7202C263.68 68.8452 263.495 69.0199 263.359 69.2443C263.222 69.4659 263.154 69.7187 263.154 70.0028V74H261.88ZM270.032 70.3778C270.032 69.3097 270.173 68.3267 270.454 67.429C270.735 66.5312 271.146 65.7031 271.685 64.9446H272.853C272.643 65.2259 272.447 65.571 272.265 65.9801C272.083 66.3892 271.924 66.8381 271.788 67.3267C271.651 67.8125 271.545 68.3168 271.468 68.8395C271.391 69.3594 271.353 69.8722 271.353 70.3778C271.353 71.054 271.42 71.7386 271.553 72.4318C271.687 73.125 271.867 73.7685 272.094 74.3622C272.322 74.956 272.575 75.4403 272.853 75.8153H271.685C271.146 75.0568 270.735 74.2287 270.454 73.331C270.173 72.4332 270.032 71.4489 270.032 70.3778ZM277.192 72.8324L274.525 70.1818L277.192 67.5312V72.8324ZM276.361 70.7273V69.6364H284.057V70.7273H276.361ZM282.996 70.7273V65.2727H284.083V70.7273H282.996ZM288.588 70.3778C288.588 71.4489 288.447 72.4332 288.166 73.331C287.885 74.2287 287.474 75.0568 286.935 75.8153H285.767C285.977 75.5341 286.173 75.1889 286.355 74.7798C286.537 74.3707 286.696 73.9233 286.832 73.4375C286.969 72.9489 287.075 72.4432 287.152 71.9205C287.229 71.3977 287.267 70.8835 287.267 70.3778C287.267 69.7045 287.2 69.0213 287.067 68.3281C286.933 67.6349 286.753 66.9915 286.526 66.3977C286.298 65.804 286.045 65.3196 285.767 64.9446H286.935C287.474 65.7031 287.885 66.5312 288.166 67.429C288.447 68.3267 288.588 69.3097 288.588 70.3778Z" fill="white"/>
</g>
<rect x="8" y="10" width="202" height="12" rx="6" fill="#FDFDFD"/>
<rect x="8" y="30" width="137" height="12" rx="6" fill="#FDFDFD"/>
<defs>
<filter id="filter0_d_487_6940" x="222.167" y="55.5833" width="80.6667" height="29.6667" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.416667"/>
<feGaussianBlur stdDeviation="0.416667"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.214953 0 0 0 0 0.364486 0 0 0 0 0.985047 0 0 0 0.08 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_487_6940"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_487_6940" result="shape"/>
</filter>
<linearGradient id="paint0_linear_487_6940" x1="0" y1="50" x2="318" y2="50" gradientUnits="userSpaceOnUse">
<stop stop-color="#ECEEF2"/>
<stop offset="1" stop-color="#E7EEFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 359 B

@@ -48,9 +48,7 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
headers: { 'X-Auth-Token' => token },
as: :json
body = response.parsed_body
expect(body['has_phone_number']).to be true
contact.reload
expect(contact.phone_number).to eq('+745623239')
expect(body['phone_number']).to eq('+745623239')
expect(response).to have_http_status(:success)
end
@@ -60,9 +58,7 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
headers: { 'X-Auth-Token' => token },
as: :json
body = response.parsed_body
expect(body['has_phone_number']).to be true
contact.reload
expect(contact.phone_number).to eq('+245623239')
expect(body['phone_number']).to eq('+245623239')
expect(response).to have_http_status(:success)
end
@@ -72,33 +68,7 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
headers: { 'X-Auth-Token' => token },
as: :json
body = response.parsed_body
expect(body['has_email']).to be true
contact.reload
expect(contact.email).to eq('test@test.com')
expect(response).to have_http_status(:success)
end
it 'dont update email if empty value email passed' do
patch '/api/v1/widget/contact',
params: params.merge({ email: '' }),
headers: { 'X-Auth-Token' => token },
as: :json
body = response.parsed_body
expect(body['has_email']).to be true
contact.reload
expect(contact.email).to eq('test@test.com')
expect(response).to have_http_status(:success)
end
it 'dont update email if nil value email passed' do
patch '/api/v1/widget/contact',
params: params.merge({ email: nil }),
headers: { 'X-Auth-Token' => token },
as: :json
body = response.parsed_body
expect(body['has_email']).to be true
contact.reload
expect(contact.email).to eq('test@test.com')
expect(body['email']).to eq('test@test.com')
expect(response).to have_http_status(:success)
end
@@ -108,9 +78,7 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
headers: { 'X-Auth-Token' => token },
as: :json
body = response.parsed_body
expect(body['has_email']).to be true
contact.reload
expect(contact.email).to eq('test-1@test.com')
expect(body['email']).to eq('test-1@test.com')
expect(response).to have_http_status(:success)
end
end
@@ -190,19 +190,17 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
describe 'POST /api/v1/widget/conversations/transcript' do
context 'with a conversation' do
it 'sends transcript email' do
contact.update(email: 'test@test.com')
mailer = double
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:conversation_transcript)
post '/api/v1/widget/conversations/transcript',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
params: { website_token: web_widget.website_token, email: 'test@test.com' },
as: :json
expect(response).to have_http_status(:success)
expect(mailer).to have_received(:conversation_transcript).with(conversation, 'test@test.com')
contact.update(email: nil)
end
end
end

Some files were not shown because too many files have changed in this diff Show More