Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f4a54d86 | ||
|
|
cb35207c26 | ||
|
|
b7353eed4a | ||
|
|
d04a5f1c51 | ||
|
|
2283c3505f | ||
|
|
7e80d7cc3b | ||
|
|
ca44d6b7a2 |
@@ -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
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
-141
@@ -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>
|
||||
-74
@@ -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>
|
||||
-98
@@ -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>
|
||||
+7
-28
@@ -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>
|
||||
|
||||
+6
-8
@@ -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>
|
||||
|
||||
+11
-18
@@ -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)"
|
||||
/>
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
-85
@@ -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>
|
||||
-210
@@ -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>
|
||||
+19
-22
@@ -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>
|
||||
|
||||
-37
@@ -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 {
|
||||
|
||||
-180
@@ -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>
|
||||
+21
-15
@@ -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;
|
||||
}
|
||||
|
||||
-12
@@ -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;
|
||||
}
|
||||
|
||||
-169
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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 |
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
Reference in New Issue
Block a user