Compare commits
77
Commits
zoom
..
feat/cw-3192
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88a4f695c0 | ||
|
|
e82a886846 | ||
|
|
25c0784f13 | ||
|
|
ce7057bd43 | ||
|
|
900e158509 | ||
|
|
bce2a07d03 | ||
|
|
dc757598f1 | ||
|
|
3b6ae772bf | ||
|
|
e8fe3c7c05 | ||
|
|
0477b8ca14 | ||
|
|
b872c1f87f | ||
|
|
934df34079 | ||
|
|
dcf188f156 | ||
|
|
50ee09a883 | ||
|
|
89eee8dc66 | ||
|
|
ffd3154c94 | ||
|
|
aed156f359 | ||
|
|
9013450e00 | ||
|
|
3b2b1b7b8c | ||
|
|
455a05481c | ||
|
|
626f487232 | ||
|
|
8fe3c91813 | ||
|
|
c4e111b554 | ||
|
|
12c5739287 | ||
|
|
78724f7459 | ||
|
|
1038d1500e | ||
|
|
7ae09ce1bf | ||
|
|
aef4b582db | ||
|
|
69ebdc63e3 | ||
|
|
2397f817fb | ||
|
|
95c83725d7 | ||
|
|
fa3bc05d3d | ||
|
|
18bfda35b3 | ||
|
|
5c9acfb4e9 | ||
|
|
e49ef773d8 | ||
|
|
e21d7552d3 | ||
|
|
6b7a707fef | ||
|
|
727fa67735 | ||
|
|
fc25f43448 | ||
|
|
631598b6b6 | ||
|
|
4e28481f27 | ||
|
|
16282f6a66 | ||
|
|
6956436a76 | ||
|
|
9a1c54a82d | ||
|
|
3e07320d22 | ||
|
|
1253264382 | ||
|
|
2ee911e33a | ||
|
|
cdcf02c943 | ||
|
|
3b7694b163 | ||
|
|
d1dd319091 | ||
|
|
9917cb4273 | ||
|
|
48452a42f4 | ||
|
|
6d4551bca2 | ||
|
|
e94b276f64 | ||
|
|
c51492c674 | ||
|
|
762a39330a | ||
|
|
44956176a1 | ||
|
|
1303469087 | ||
|
|
f78f278e2f | ||
|
|
b017d05ed9 | ||
|
|
bd97226c95 | ||
|
|
a8b930e351 | ||
|
|
e1e8857195 | ||
|
|
b5c6d90abd | ||
|
|
fd832d7593 | ||
|
|
29e44ac6d0 | ||
|
|
58ee2e125a | ||
|
|
89d0b2cb6e | ||
|
|
476077ab84 | ||
|
|
586552013e | ||
|
|
3dae3ff3ad | ||
|
|
29171565ed | ||
|
|
55fcbe2dde | ||
|
|
7f4b2d66d4 | ||
|
|
804a42c271 | ||
|
|
561fafa198 | ||
|
|
aaf70cf1cf |
@@ -1,7 +1,2 @@
|
||||
## All javascript files should be reviewed by pranav before merging
|
||||
*.js @pranavrajs
|
||||
*.vue @pranavrajs
|
||||
|
||||
|
||||
## All enterprise related files should be reviewed by sojan before merging
|
||||
/enterprise/* @sojan-official
|
||||
|
||||
@@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth', '>= 2.1.2'
|
||||
gem 'omniauth-google-oauth2'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.2'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0'
|
||||
|
||||
## Gems for reponse bot
|
||||
|
||||
+17
-12
@@ -237,9 +237,8 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.7.4)
|
||||
faraday-net_http (>= 2.0, < 3.1)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday (2.9.0)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-mashify (0.1.1)
|
||||
@@ -247,7 +246,8 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.0.2)
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
@@ -394,7 +394,8 @@ GEM
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
jwt (2.7.0)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
@@ -470,6 +471,8 @@ GEM
|
||||
multipart-post (2.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.9)
|
||||
@@ -515,11 +518,11 @@ GEM
|
||||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-google-oauth2 (1.1.1)
|
||||
omniauth-google-oauth2 (1.1.2)
|
||||
jwt (>= 2.0)
|
||||
oauth2 (~> 2.0.6)
|
||||
oauth2 (~> 2.0)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-oauth2 (~> 1.8.0)
|
||||
omniauth-oauth2 (~> 1.8)
|
||||
omniauth-oauth2 (1.8.0)
|
||||
oauth2 (>= 1.4, < 3)
|
||||
omniauth (~> 2.0)
|
||||
@@ -559,7 +562,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.7.3)
|
||||
rack (2.2.8.1)
|
||||
rack (2.2.9)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.4.0)
|
||||
@@ -568,7 +571,8 @@ GEM
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.2.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-protection (3.1.0)
|
||||
rack-protection (3.2.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
rack-proxy (0.7.6)
|
||||
rack
|
||||
@@ -790,11 +794,12 @@ GEM
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (2.4.2)
|
||||
uniform_notifier (1.16.0)
|
||||
uri (0.13.0)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (4.0.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.3)
|
||||
version_gem (1.1.4)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.2.1)
|
||||
@@ -907,7 +912,7 @@ DEPENDENCIES
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
newrelic_rpm
|
||||
omniauth (>= 2.1.2)
|
||||
omniauth-google-oauth2
|
||||
omniauth-google-oauth2 (>= 1.1.2)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0)
|
||||
pg
|
||||
|
||||
@@ -17,6 +17,9 @@ db_migrate:
|
||||
db_seed:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:seed
|
||||
|
||||
db_reset:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:reset
|
||||
|
||||
db:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:chatwoot_prepare
|
||||
|
||||
@@ -49,4 +52,4 @@ debug_worker:
|
||||
docker:
|
||||
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
|
||||
|
||||
.PHONY: setup db_create db_migrate db_seed db console server burn docker run force_run debug debug_worker
|
||||
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
|
||||
|
||||
@@ -16,7 +16,6 @@ class AgentBuilder
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
send_confirmation_if_required
|
||||
create_account_user
|
||||
end
|
||||
@user
|
||||
@@ -34,11 +33,6 @@ class AgentBuilder
|
||||
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
|
||||
end
|
||||
|
||||
# Sends confirmation instructions if the user is persisted and not confirmed.
|
||||
def send_confirmation_if_required
|
||||
@user.send_confirmation_instructions if user_needs_confirmation?
|
||||
end
|
||||
|
||||
# Checks if the user needs confirmation.
|
||||
# @return [Boolean] true if the user is persisted and not confirmed, false otherwise.
|
||||
def user_needs_confirmation?
|
||||
|
||||
@@ -53,7 +53,23 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
|
||||
@conversation ||= set_conversation_based_on_inbox_config
|
||||
end
|
||||
|
||||
def set_conversation_based_on_inbox_config
|
||||
if @inbox.lock_to_single_conversation
|
||||
Conversation.where(conversation_params).order(created_at: :desc).first || build_conversation
|
||||
else
|
||||
find_or_build_for_multiple_conversations
|
||||
end
|
||||
end
|
||||
|
||||
def find_or_build_for_multiple_conversations
|
||||
# If lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
|
||||
last_conversation = Conversation.where(conversation_params).where.not(status: :resolved).order(created_at: :desc).first
|
||||
return build_conversation if last_conversation.nil?
|
||||
|
||||
last_conversation
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
|
||||
@@ -69,9 +69,28 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.where(conversation_params).find_by(
|
||||
"additional_attributes ->> 'type' = 'instagram_direct_message'"
|
||||
) || build_conversation
|
||||
@conversation ||= set_conversation_based_on_inbox_config
|
||||
end
|
||||
|
||||
def instagram_direct_message_conversation
|
||||
Conversation.where(conversation_params)
|
||||
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
|
||||
end
|
||||
|
||||
def set_conversation_based_on_inbox_config
|
||||
if @inbox.lock_to_single_conversation
|
||||
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
|
||||
else
|
||||
find_or_build_for_multiple_conversations
|
||||
end
|
||||
end
|
||||
|
||||
def find_or_build_for_multiple_conversations
|
||||
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
|
||||
|
||||
return build_conversation if last_conversation.nil?
|
||||
|
||||
last_conversation
|
||||
end
|
||||
|
||||
def message_content
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
account: Current.account
|
||||
)
|
||||
|
||||
builder.perform
|
||||
@agent = builder.perform
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -2,13 +2,9 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
||||
before_action :authorize_request
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
authenticate_twilio
|
||||
build_inbox
|
||||
setup_webhooks if @twilio_channel.sms?
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
process_create
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -17,6 +13,14 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
||||
authorize ::Inbox
|
||||
end
|
||||
|
||||
def process_create
|
||||
ActiveRecord::Base.transaction do
|
||||
authenticate_twilio
|
||||
build_inbox
|
||||
setup_webhooks if @twilio_channel.sms?
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate_twilio
|
||||
client = if permitted_params[:api_key_sid].present?
|
||||
Twilio::REST::Client.new(permitted_params[:api_key_sid], permitted_params[:auth_token], permitted_params[:account_sid])
|
||||
|
||||
@@ -65,6 +65,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
contacts = result[:contacts]
|
||||
@contacts_count = result[:count]
|
||||
@contacts = fetch_contacts(contacts)
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def contactable_inboxes
|
||||
|
||||
@@ -36,10 +36,18 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@conversation.update!(permitted_update_params)
|
||||
end
|
||||
|
||||
def filter
|
||||
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
|
||||
@conversations = result[:conversations]
|
||||
@conversations_count = result[:count]
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def mute
|
||||
@@ -110,6 +118,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
private
|
||||
|
||||
def permitted_update_params
|
||||
# TODO: Move the other conversation attributes to this method and remove specific endpoints for each attribute
|
||||
params.permit(:priority)
|
||||
end
|
||||
|
||||
def update_last_seen_on_conversation(last_seen_at, update_assignee)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
@conversation.update_column(:agent_last_seen_at, last_seen_at)
|
||||
@@ -176,3 +189,5 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
@conversation.assignee_id? && Current.user == @conversation.assignee
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController')
|
||||
|
||||
@@ -33,10 +33,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def transcript
|
||||
if permitted_params[:email].present? && conversation.present?
|
||||
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
|
||||
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
|
||||
conversation,
|
||||
permitted_params[:email]
|
||||
conversation.contact.email
|
||||
)&.deliver_later
|
||||
end
|
||||
head :ok
|
||||
|
||||
@@ -25,3 +25,4 @@ class ApplicationController < ActionController::Base
|
||||
}
|
||||
end
|
||||
end
|
||||
ApplicationController.include_mod_with('Concerns::ApplicationControllerConcern')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module AccessTokenAuthHelper
|
||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create],
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update],
|
||||
'api/v1/accounts/conversations/messages' => ['create'],
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
}.freeze
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module DomainHelper
|
||||
def self.chatwoot_domain?(domain = request.host)
|
||||
[URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,7 @@ module SwitchLocale
|
||||
def switch_locale(&)
|
||||
# priority is for locale set in query string (mostly for widget/from js sdk)
|
||||
locale ||= locale_from_params
|
||||
locale ||= locale_from_custom_domain
|
||||
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
|
||||
locale ||= locale_from_env_variable
|
||||
set_locale(locale, &)
|
||||
@@ -16,6 +17,20 @@ module SwitchLocale
|
||||
set_locale(locale, &)
|
||||
end
|
||||
|
||||
# If the request is coming from a custom domain, it should be for a helpcenter portal
|
||||
# We will use the portal locale in such cases
|
||||
def locale_from_custom_domain(&)
|
||||
return if params[:locale]
|
||||
|
||||
domain = request.host
|
||||
return if DomainHelper.chatwoot_domain?(domain)
|
||||
|
||||
@portal = Portal.find_by(custom_domain: domain)
|
||||
return unless @portal
|
||||
|
||||
@portal.default_locale
|
||||
end
|
||||
|
||||
def set_locale(locale, &)
|
||||
# if locale is empty, use default_locale
|
||||
locale ||= I18n.default_locale
|
||||
|
||||
@@ -18,6 +18,7 @@ class DashboardController < ActionController::Base
|
||||
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
|
||||
'INSTALLATION_NAME',
|
||||
'WIDGET_BRAND_URL', 'TERMS_URL',
|
||||
'BRAND_URL', 'BRAND_NAME',
|
||||
'PRIVACY_URL',
|
||||
'DISPLAY_MANIFEST',
|
||||
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
|
||||
|
||||
@@ -4,6 +4,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
wrap_parameters format: []
|
||||
before_action :process_sso_auth_token, only: [:create]
|
||||
|
||||
def new
|
||||
redirect_to login_page_url(error: 'access-denied')
|
||||
end
|
||||
|
||||
def create
|
||||
# Authenticate user via the temporary sso auth token
|
||||
if params[:sso_auth_token].present? && @resource.present?
|
||||
@@ -21,6 +25,12 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
|
||||
private
|
||||
|
||||
def login_page_url(error: nil)
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
|
||||
"#{frontend_url}/app/login?error=#{error}"
|
||||
end
|
||||
|
||||
def authenticate_resource_with_sso_token
|
||||
@token = @resource.create_token
|
||||
@resource.save!
|
||||
|
||||
@@ -47,7 +47,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
@locale = if article.category.present?
|
||||
article.category.locale
|
||||
else
|
||||
'en'
|
||||
article.portal.default_locale
|
||||
end
|
||||
|
||||
I18n.with_locale(@locale, &)
|
||||
|
||||
@@ -8,8 +8,7 @@ class PublicController < ActionController::Base
|
||||
|
||||
def ensure_custom_domain_request
|
||||
domain = request.host
|
||||
|
||||
return if [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
|
||||
return if DomainHelper.chatwoot_domain?(domain)
|
||||
|
||||
@portal = ::Portal.find_by(custom_domain: domain)
|
||||
return if @portal.present?
|
||||
|
||||
@@ -163,10 +163,14 @@ class ConversationFinder
|
||||
params[:page] || 1
|
||||
end
|
||||
|
||||
def conversations
|
||||
@conversations = @conversations.includes(
|
||||
def conversations_base_query
|
||||
@conversations.includes(
|
||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
|
||||
)
|
||||
end
|
||||
|
||||
def conversations
|
||||
@conversations = conversations_base_query
|
||||
|
||||
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
|
||||
@conversations = @conversations.send(sort_by, sort_order)
|
||||
@@ -178,3 +182,4 @@ class ConversationFinder
|
||||
end
|
||||
end
|
||||
end
|
||||
ConversationFinder.prepend_mod_with('ConversationFinder')
|
||||
|
||||
@@ -2,4 +2,11 @@ module ApplicationHelper
|
||||
def available_locales_with_name
|
||||
LANGUAGES_CONFIG.map { |_key, val| val.slice(:name, :iso_639_1_code) }
|
||||
end
|
||||
|
||||
def feature_help_urls
|
||||
features = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
|
||||
features.each_with_object({}) do |feature, hash|
|
||||
hash[feature['name']] = feature['help_url'] if feature['help_url']
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
module FilterHelper
|
||||
def build_condition_query(model_filters, query_hash, current_index)
|
||||
current_filter = model_filters[query_hash['attribute_key']]
|
||||
|
||||
# Throw InvalidOperator Error if the attribute is a standard attribute
|
||||
# and the operator is not allowed in the config
|
||||
if current_filter.present? && current_filter['filter_operators'].exclude?(query_hash[:filter_operator])
|
||||
raise CustomExceptions::CustomFilter::InvalidOperator.new(
|
||||
attribute_name: query_hash['attribute_key'],
|
||||
allowed_keys: current_filter['filter_operators']
|
||||
)
|
||||
end
|
||||
|
||||
# Every other filter expects a value to be present
|
||||
if %w[is_present is_not_present].exclude?(query_hash[:filter_operator]) && query_hash['values'].blank?
|
||||
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: query_hash['attribute_key'])
|
||||
end
|
||||
|
||||
condition_query = build_condition_query_string(current_filter, query_hash, current_index)
|
||||
# The query becomes empty only when it doesn't match to any supported
|
||||
# standard attribute or custom attribute defined in the account.
|
||||
if condition_query.empty?
|
||||
raise CustomExceptions::CustomFilter::InvalidAttribute.new(key: query_hash['attribute_key'],
|
||||
allowed_keys: model_filters.keys)
|
||||
end
|
||||
|
||||
condition_query
|
||||
end
|
||||
|
||||
def build_condition_query_string(current_filter, query_hash, current_index)
|
||||
filter_operator_value = filter_operation(query_hash, current_index)
|
||||
|
||||
return handle_nil_filter(query_hash, current_index) if current_filter.nil?
|
||||
|
||||
case current_filter['attribute_type']
|
||||
when 'additional_attributes'
|
||||
handle_additional_attributes(query_hash, filter_operator_value, current_filter['data_type'])
|
||||
else
|
||||
handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_nil_filter(query_hash, current_index)
|
||||
attribute_type = "#{filter_config[:entity].downcase}_attribute"
|
||||
custom_attribute_query(query_hash, attribute_type, current_index)
|
||||
end
|
||||
|
||||
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
|
||||
if data_type == 'text_case_insensitive'
|
||||
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
else
|
||||
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]} "
|
||||
end
|
||||
end
|
||||
|
||||
def handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
|
||||
case current_filter['data_type']
|
||||
when 'date'
|
||||
date_filter(current_filter, query_hash, filter_operator_value)
|
||||
when 'labels'
|
||||
tag_filter_query(query_hash, current_index)
|
||||
when 'text_case_insensitive'
|
||||
text_case_insensitive_filter(query_hash, filter_operator_value)
|
||||
else
|
||||
default_filter(query_hash, filter_operator_value)
|
||||
end
|
||||
end
|
||||
|
||||
def date_filter(current_filter, query_hash, filter_operator_value)
|
||||
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
|
||||
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
|
||||
end
|
||||
|
||||
def text_case_insensitive_filter(query_hash, filter_operator_value)
|
||||
"LOWER(#{filter_config[:table_name]}.#{query_hash[:attribute_key]}) " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
end
|
||||
|
||||
def default_filter(query_hash, filter_operator_value)
|
||||
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
end
|
||||
end
|
||||
@@ -84,6 +84,24 @@ class ReportsAPI extends ApiClient {
|
||||
params: { since, until, business_hours: businessHours },
|
||||
});
|
||||
}
|
||||
|
||||
getBotMetrics({ from, to } = {}) {
|
||||
return axios.get(`${this.url}/bot_metrics`, {
|
||||
params: { since: from, until: to },
|
||||
});
|
||||
}
|
||||
|
||||
getBotSummary({ from, to, groupBy, businessHours } = {}) {
|
||||
return axios.get(`${this.url}/bot_summary`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
type: 'account',
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReportsAPI();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class SLAReportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('applied_slas', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({
|
||||
from,
|
||||
to,
|
||||
assigned_agent_id,
|
||||
inbox_id,
|
||||
team_id,
|
||||
sla_policy_id,
|
||||
label_list,
|
||||
page,
|
||||
} = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
assigned_agent_id,
|
||||
inbox_id,
|
||||
team_id,
|
||||
sla_policy_id,
|
||||
label_list,
|
||||
page,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
download({
|
||||
from,
|
||||
to,
|
||||
assigned_agent_id,
|
||||
inbox_id,
|
||||
team_id,
|
||||
sla_policy_id,
|
||||
} = {}) {
|
||||
return axios.get(`${this.url}/download`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
assigned_agent_id,
|
||||
inbox_id,
|
||||
team_id,
|
||||
sla_policy_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics({
|
||||
from,
|
||||
to,
|
||||
assigned_agent_id,
|
||||
inbox_id,
|
||||
team_id,
|
||||
label_list,
|
||||
sla_policy_id,
|
||||
} = {}) {
|
||||
return axios.get(`${this.url}/metrics`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
assigned_agent_id,
|
||||
inbox_id,
|
||||
label_list,
|
||||
team_id,
|
||||
sla_policy_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new SLAReportsAPI();
|
||||
@@ -111,6 +111,40 @@ describe('#Reports API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('#getBotMetrics', () => {
|
||||
reportsAPI.getBotMetrics({ from: 1621103400, to: 1621621800 });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/bot_metrics',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getBotSummary', () => {
|
||||
reportsAPI.getBotSummary({
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
groupBy: 'date',
|
||||
businessHours: true,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/bot_summary',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
type: 'account',
|
||||
group_by: 'date',
|
||||
business_hours: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getConversationMetric', () => {
|
||||
reportsAPI.getConversationMetric('account');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import SLAReportsAPI from '../slaReports';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#SLAReports API', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(SLAReportsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(SLAReportsAPI.apiVersion).toBe('/api/v1');
|
||||
expect(SLAReportsAPI).toHaveProperty('get');
|
||||
expect(SLAReportsAPI).toHaveProperty('getMetrics');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: jest.fn(() => Promise.resolve()),
|
||||
get: jest.fn(() => Promise.resolve()),
|
||||
patch: jest.fn(() => Promise.resolve()),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#get', () => {
|
||||
SLAReportsAPI.get({
|
||||
page: 1,
|
||||
from: 1622485800,
|
||||
to: 1623695400,
|
||||
assigned_agent_id: 1,
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
sla_policy_id: 1,
|
||||
label_list: ['label1'],
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/applied_slas', {
|
||||
params: {
|
||||
page: 1,
|
||||
since: 1622485800,
|
||||
until: 1623695400,
|
||||
assigned_agent_id: 1,
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
sla_policy_id: 1,
|
||||
label_list: ['label1'],
|
||||
},
|
||||
});
|
||||
});
|
||||
it('#getMetrics', () => {
|
||||
SLAReportsAPI.getMetrics({
|
||||
from: 1622485800,
|
||||
to: 1623695400,
|
||||
assigned_agent_id: 1,
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
sla_policy_id: 1,
|
||||
label_list: ['label1'],
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/applied_slas/metrics',
|
||||
{
|
||||
params: {
|
||||
since: 1622485800,
|
||||
until: 1623695400,
|
||||
assigned_agent_id: 1,
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
sla_policy_id: 1,
|
||||
label_list: ['label1'],
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
it('#download', () => {
|
||||
SLAReportsAPI.download({
|
||||
from: 1622485800,
|
||||
to: 1623695400,
|
||||
assigned_agent_id: 1,
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
sla_policy_id: 1,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/applied_slas/download',
|
||||
{
|
||||
params: {
|
||||
since: 1622485800,
|
||||
until: 1623695400,
|
||||
assigned_agent_id: 1,
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
sla_policy_id: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
@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;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// scss-lint:disable SpaceAfterPropertyColon
|
||||
// @import 'shared/assets/fonts/inter';
|
||||
|
||||
@import 'shared/assets/fonts/inter';
|
||||
// Inter,
|
||||
html,
|
||||
body {
|
||||
font-family:
|
||||
'PlusJakarta',
|
||||
Inter,
|
||||
-apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Tahoma,
|
||||
Arial,
|
||||
sans-serif !important;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@@ -153,6 +153,29 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -63,12 +63,12 @@ input:focus {
|
||||
}
|
||||
|
||||
// Inputs
|
||||
input[type='text'],
|
||||
input[type='number'],
|
||||
input[type='password'],
|
||||
input[type='date'],
|
||||
input[type='email'],
|
||||
input[type='url'] {
|
||||
input[type='text']:not(.reset-base),
|
||||
input[type='number']:not(.reset-base),
|
||||
input[type='password']:not(.reset-base),
|
||||
input[type='date']:not(.reset-base),
|
||||
input[type='email']:not(.reset-base),
|
||||
input[type='url']:not(.reset-base) {
|
||||
@apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
|
||||
|
||||
&[disabled] {
|
||||
|
||||
@@ -118,7 +118,7 @@ button {
|
||||
@apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900;
|
||||
|
||||
&.secondary {
|
||||
@apply text-slate-700 border-slate-200 dark:border-slate-600 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
|
||||
@apply text-slate-700 border-slate-100 dark:border-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
|
||||
}
|
||||
|
||||
&.success {
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
|
||||
.modal-image {
|
||||
@apply max-h-[76vh] max-w-[76vw];
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.modal-video {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
class="conversations-list-wrap flex-basis-clamp flex-shrink-0 overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
|
||||
:class="{
|
||||
hide: !showConversationList,
|
||||
'list--full-width': isOnExpandedLayout,
|
||||
}"
|
||||
class="flex flex-col flex-shrink-0 overflow-hidden border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
|
||||
:class="[
|
||||
{ hidden: !showConversationList },
|
||||
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
class="flex items-center justify-between py-0 px-4"
|
||||
class="flex items-center justify-between px-4 py-0"
|
||||
:class="{
|
||||
'pb-3 border-b border-slate-75 dark:border-slate-700':
|
||||
hasAppliedFiltersOrActiveFolders,
|
||||
@@ -16,7 +16,7 @@
|
||||
>
|
||||
<div class="flex max-w-[85%] justify-center items-center">
|
||||
<h1
|
||||
class="text-xl break-words overflow-hidden whitespace-nowrap font-medium text-ellipsis text-black-900 dark:text-slate-100 mb-0"
|
||||
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
|
||||
:title="pageTitle"
|
||||
>
|
||||
{{ pageTitle }}
|
||||
@@ -107,7 +107,7 @@
|
||||
|
||||
<p
|
||||
v-if="!chatListLoading && !conversationList.length"
|
||||
class="overflow-auto p-4 flex justify-center items-center"
|
||||
class="flex items-center justify-center p-4 overflow-auto"
|
||||
>
|
||||
{{ $t('CHAT_LIST.LIST.404') }}
|
||||
</p>
|
||||
@@ -127,7 +127,7 @@
|
||||
/>
|
||||
<div
|
||||
ref="conversationList"
|
||||
class="conversations-list flex-1"
|
||||
class="flex-1 conversations-list"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<virtual-list
|
||||
@@ -136,16 +136,16 @@
|
||||
:data-sources="conversationList"
|
||||
:data-component="itemComponent"
|
||||
:extra-props="virtualListExtraProps"
|
||||
class="w-full overflow-auto h-full"
|
||||
class="w-full h-full overflow-auto"
|
||||
footer-tag="div"
|
||||
>
|
||||
<template #footer>
|
||||
<div v-if="chatListLoading" class="text-center">
|
||||
<span class="spinner mt-4 mb-4" />
|
||||
<span class="mt-4 mb-4 spinner" />
|
||||
</div>
|
||||
<p
|
||||
v-if="showEndOfListMessage"
|
||||
class="text-center text-slate-400 dark:text-slate-300 p-4"
|
||||
class="p-4 text-center text-slate-400 dark:text-slate-300"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
@@ -1034,24 +1034,10 @@ export default {
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversations-list-wrap {
|
||||
&.hide {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
&.list--full-width {
|
||||
@apply basis-full;
|
||||
}
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
@apply overflow-hidden hover:overflow-y-auto;
|
||||
}
|
||||
|
||||
.load-more--button {
|
||||
@apply text-center rounded-none;
|
||||
}
|
||||
|
||||
.tab--chat-type {
|
||||
@apply py-0 px-4;
|
||||
|
||||
|
||||
@@ -2,23 +2,27 @@
|
||||
<div class="py-3 px-4">
|
||||
<div class="flex items-center mb-1">
|
||||
<h4 class="text-sm flex items-center m-0 w-full error">
|
||||
<div v-if="isAttributeTypeCheckbox" class="checkbox-wrap">
|
||||
<div v-if="isAttributeTypeCheckbox" class="flex items-center">
|
||||
<input
|
||||
v-model="editedValue"
|
||||
class="checkbox"
|
||||
class="!my-0 mr-2 ml-0"
|
||||
type="checkbox"
|
||||
@change="onUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span
|
||||
class="attribute-name w-full text-slate-800 dark:text-slate-100 font-medium text-sm mb-0"
|
||||
:class="{ error: $v.editedValue.$error }"
|
||||
class="w-full font-medium text-sm mb-0"
|
||||
:class="
|
||||
$v.editedValue.$error
|
||||
? 'text-red-400 dark:text-red-500'
|
||||
: 'text-slate-800 dark:text-slate-100'
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<woot-button
|
||||
v-if="showActions"
|
||||
v-if="showCopyAndDeleteButton"
|
||||
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
|
||||
variant="link"
|
||||
size="medium"
|
||||
@@ -31,7 +35,7 @@
|
||||
</h4>
|
||||
</div>
|
||||
<div v-if="notAttributeTypeCheckboxAndList">
|
||||
<div v-show="isEditing">
|
||||
<div v-if="isEditing" v-on-clickaway="onClickAway">
|
||||
<div class="mb-2 w-full flex items-center">
|
||||
<input
|
||||
ref="inputfield"
|
||||
@@ -61,7 +65,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-show="!isEditing"
|
||||
class="value--view"
|
||||
class="flex group"
|
||||
:class="{ 'is-editable': showActions }"
|
||||
>
|
||||
<a
|
||||
@@ -69,35 +73,35 @@
|
||||
:href="hrefURL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="value inline-block rounded-sm mb-0 break-all py-0.5 px-1"
|
||||
class="group-hover:bg-slate-50 group-hover:dark:bg-slate-700 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
|
||||
>
|
||||
{{ urlValue }}
|
||||
</a>
|
||||
<p
|
||||
v-else
|
||||
class="value inline-block rounded-sm mb-0 break-all py-0.5 px-1"
|
||||
class="group-hover:bg-slate-50 group-hover:dark:bg-slate-700 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
|
||||
>
|
||||
{{ displayValue || '---' }}
|
||||
</p>
|
||||
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
|
||||
<woot-button
|
||||
v-if="showActions"
|
||||
v-if="showCopyAndDeleteButton"
|
||||
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
|
||||
variant="link"
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
icon="clipboard"
|
||||
class-names="edit-button"
|
||||
class-names="hidden group-hover:flex !w-6 flex-shrink-0"
|
||||
@click="onCopy"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="showActions"
|
||||
v-if="showEditButton"
|
||||
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
|
||||
variant="link"
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
icon="edit"
|
||||
class-names="edit-button"
|
||||
class-names="hidden group-hover:flex !w-6 flex-shrink-0"
|
||||
@click="onEdit"
|
||||
/>
|
||||
</div>
|
||||
@@ -126,6 +130,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { required, url } from 'vuelidate/lib/validators';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
@@ -138,7 +143,7 @@ export default {
|
||||
components: {
|
||||
MultiselectDropdown,
|
||||
},
|
||||
mixins: [customAttributeMixin],
|
||||
mixins: [customAttributeMixin, clickaway],
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
values: { type: Array, default: () => [] },
|
||||
@@ -160,11 +165,18 @@ export default {
|
||||
editedValue: null,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
showCopyAndDeleteButton() {
|
||||
return this.value && this.showActions;
|
||||
},
|
||||
showEditButton() {
|
||||
return !this.value && this.showActions;
|
||||
},
|
||||
displayValue() {
|
||||
if (this.isAttributeTypeDate) {
|
||||
return new Date(this.value || new Date()).toLocaleDateString();
|
||||
return this.value
|
||||
? new Date(this.value || new Date()).toLocaleDateString()
|
||||
: '';
|
||||
}
|
||||
if (this.isAttributeTypeCheckbox) {
|
||||
return this.value === 'false' ? false : this.value;
|
||||
@@ -230,6 +242,10 @@ export default {
|
||||
this.isEditing = false;
|
||||
this.editedValue = this.formattedValue;
|
||||
},
|
||||
contactId() {
|
||||
// Fix to solve validation not resetting when contactId changes in contact page
|
||||
this.$v.$reset();
|
||||
},
|
||||
},
|
||||
|
||||
validations() {
|
||||
@@ -268,6 +284,10 @@ export default {
|
||||
this.$refs.inputfield.focus();
|
||||
}
|
||||
},
|
||||
onClickAway() {
|
||||
this.$v.$reset();
|
||||
this.isEditing = false;
|
||||
},
|
||||
onEdit() {
|
||||
this.isEditing = true;
|
||||
this.$nextTick(() => {
|
||||
@@ -294,6 +314,7 @@ export default {
|
||||
},
|
||||
onDelete() {
|
||||
this.isEditing = false;
|
||||
this.$v.$reset();
|
||||
this.$emit('delete', this.attributeKey);
|
||||
},
|
||||
onCopy() {
|
||||
@@ -304,35 +325,6 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.checkbox-wrap {
|
||||
@apply flex items-center;
|
||||
}
|
||||
.checkbox {
|
||||
@apply my-0 mr-2 ml-0;
|
||||
}
|
||||
.attribute-name {
|
||||
&.error {
|
||||
@apply text-red-400 dark:text-red-500;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.value--view {
|
||||
@apply flex;
|
||||
|
||||
&.is-editable:hover {
|
||||
.value {
|
||||
@apply bg-slate-50 dark:bg-slate-700 mb-0;
|
||||
}
|
||||
.edit-button {
|
||||
@apply block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.selector-wrap {
|
||||
@apply m-0 top-1;
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<div
|
||||
:class="modalContainerClassName"
|
||||
:class="{
|
||||
'modal-container rtl:text-right shadow-md max-h-full overflow-auto relative bg-white dark:bg-slate-800 skip-context-menu': true,
|
||||
'rounded-xl w-[37.5rem]': !fullWidth,
|
||||
'items-center rounded-none flex h-full justify-center w-full':
|
||||
fullWidth,
|
||||
[size]: true,
|
||||
}"
|
||||
@mouse.stop
|
||||
@mousedown="event => event.stopPropagation()"
|
||||
>
|
||||
@@ -16,7 +22,7 @@
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
variant="clear"
|
||||
class="absolute ltr:right-2 rtl:left-2 top-2 z-10"
|
||||
class="absolute z-10 ltr:right-2 rtl:left-2 top-2"
|
||||
@click="close"
|
||||
/>
|
||||
<slot />
|
||||
@@ -60,15 +66,6 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
modalContainerClassName() {
|
||||
let className =
|
||||
'modal-container rtl:text-right shadow-md rounded-sm max-h-full overflow-auto relative w-[37.5rem] bg-white dark:bg-slate-800 skip-context-menu';
|
||||
if (this.fullWidth) {
|
||||
return `${className} items-center rounded-none flex h-full justify-center w-full`;
|
||||
}
|
||||
|
||||
return `${className} ${this.size}`;
|
||||
},
|
||||
modalClassName() {
|
||||
const modalClassNameMap = {
|
||||
centered: '',
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-start pt-8 px-8 pb-0">
|
||||
<div class="flex flex-col items-start px-8 pt-8 pb-0">
|
||||
<img v-if="headerImage" :src="headerImage" alt="No image" />
|
||||
<h2
|
||||
ref="modalHeaderTitle"
|
||||
class="text-slate-800 text-lg font-semibold dark:text-slate-50"
|
||||
class="text-base font-semibold leading-6 text-slate-800 dark:text-slate-50"
|
||||
>
|
||||
{{ headerTitle }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="headerContent"
|
||||
ref="modalHeaderContent"
|
||||
class="w-full break-words text-slate-600 mt-2 text-sm dark:text-slate-300"
|
||||
class="w-full mt-2 text-sm leading-5 break-words text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{{ headerContent }}
|
||||
<span
|
||||
v-if="headerContentValue"
|
||||
class="font-semibold text-sm text-slate-600 dark:text-slate-300"
|
||||
class="text-sm font-semibold text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{{ headerContentValue }}
|
||||
</span>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const reports = accountId => ({
|
||||
@@ -6,10 +7,12 @@ const reports = accountId => ({
|
||||
'account_overview_reports',
|
||||
'conversation_reports',
|
||||
'csat_reports',
|
||||
'bot_reports',
|
||||
'agent_reports',
|
||||
'label_reports',
|
||||
'inbox_reports',
|
||||
'team_reports',
|
||||
'sla_reports',
|
||||
],
|
||||
menuItems: [
|
||||
{
|
||||
@@ -33,6 +36,14 @@ const reports = accountId => ({
|
||||
toState: frontendURL(`accounts/${accountId}/reports/csat`),
|
||||
toStateName: 'csat_reports',
|
||||
},
|
||||
{
|
||||
icon: 'bot',
|
||||
label: 'REPORTS_BOT',
|
||||
hasSubMenu: false,
|
||||
featureFlag: FEATURE_FLAGS.RESPONSE_BOT,
|
||||
toState: frontendURL(`accounts/${accountId}/reports/bot`),
|
||||
toStateName: 'bot_reports',
|
||||
},
|
||||
{
|
||||
icon: 'people',
|
||||
label: 'REPORTS_AGENT',
|
||||
@@ -61,6 +72,14 @@ const reports = accountId => ({
|
||||
toState: frontendURL(`accounts/${accountId}/reports/teams`),
|
||||
toStateName: 'team_reports',
|
||||
},
|
||||
{
|
||||
icon: 'document-list-clock',
|
||||
label: 'REPORTS_SLA',
|
||||
hasSubMenu: false,
|
||||
featureFlag: FEATURE_FLAGS.SLA,
|
||||
toState: frontendURL(`accounts/${accountId}/reports/sla`),
|
||||
toStateName: 'sla_reports',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ const settings = accountId => ({
|
||||
beta: true,
|
||||
},
|
||||
{
|
||||
icon: 'key',
|
||||
icon: 'document-list-clock',
|
||||
label: 'SLA',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/sla/list`),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="ltr:mr-1 rtl:ml-1 mb-1"
|
||||
class="inline-flex ltr:mr-1 rtl:ml-1 mb-1"
|
||||
:class="labelClass"
|
||||
:style="labelStyle"
|
||||
:title="description"
|
||||
@@ -111,7 +111,7 @@ export default {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.label {
|
||||
@apply inline-flex items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100 border border-solid border-slate-75 dark:border-slate-600 h-6;
|
||||
@apply items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100 border border-solid border-slate-75 dark:border-slate-600 h-6;
|
||||
|
||||
&.small {
|
||||
@apply text-xs py-0.5 px-1 leading-tight h-5;
|
||||
|
||||
@@ -1,171 +1,48 @@
|
||||
<template>
|
||||
<footer
|
||||
v-if="isFooterVisible"
|
||||
class="bg-white dark:bg-slate-800 h-12 border-t border-solid border-slate-75 dark:border-slate-700/50 flex items-center justify-between px-6"
|
||||
class="h-12 flex items-center justify-between px-6"
|
||||
>
|
||||
<div class="left-aligned-wrap">
|
||||
<div class="text-xs text-slate-600 dark:text-slate-200">
|
||||
<strong>{{ firstIndex }}</strong>
|
||||
- <strong>{{ lastIndex }}</strong> of
|
||||
<strong>{{ totalCount }}</strong> items
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-aligned-wrap">
|
||||
<div
|
||||
v-if="totalCount"
|
||||
class="primary button-group pagination-button-group"
|
||||
>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
class-names="goto-first"
|
||||
:is-disabled="hasFirstPage"
|
||||
@click="onFirstPage"
|
||||
>
|
||||
<fluent-icon icon="chevron-left" size="18" />
|
||||
<fluent-icon
|
||||
icon="chevron-left"
|
||||
size="18"
|
||||
:class="pageFooterIconClass"
|
||||
/>
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasPrevPage"
|
||||
@click="onPrevPage"
|
||||
>
|
||||
<fluent-icon icon="chevron-left" size="18" />
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
@click.prevent
|
||||
>
|
||||
{{ currentPage }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasNextPage"
|
||||
@click="onNextPage"
|
||||
>
|
||||
<fluent-icon icon="chevron-right" size="18" />
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
class-names="goto-last"
|
||||
:is-disabled="hasLastPage"
|
||||
@click="onLastPage"
|
||||
>
|
||||
<fluent-icon icon="chevron-right" size="18" />
|
||||
<fluent-icon
|
||||
icon="chevron-right"
|
||||
size="18"
|
||||
:class="pageFooterIconClass"
|
||||
/>
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
<table-footer-results
|
||||
:first-index="firstIndex"
|
||||
:last-index="lastIndex"
|
||||
:total-count="totalCount"
|
||||
/>
|
||||
<table-footer-pagination
|
||||
v-if="totalCount"
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
:total-count="totalCount"
|
||||
:page-size="pageSize"
|
||||
@page-change="$emit('page-change', $event)"
|
||||
/>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||
|
||||
export default {
|
||||
components: {},
|
||||
mixins: [rtlMixin],
|
||||
props: {
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 25,
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import TableFooterResults from './TableFooterResults.vue';
|
||||
import TableFooterPagination from './TableFooterPagination.vue';
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
computed: {
|
||||
pageFooterIconClass() {
|
||||
return this.isRTLView ? '-mr-3' : '-ml-3';
|
||||
},
|
||||
isFooterVisible() {
|
||||
return this.totalCount && !(this.firstIndex > this.totalCount);
|
||||
},
|
||||
firstIndex() {
|
||||
return this.pageSize * (this.currentPage - 1) + 1;
|
||||
},
|
||||
lastIndex() {
|
||||
return Math.min(this.totalCount, this.pageSize * this.currentPage);
|
||||
},
|
||||
searchButtonClass() {
|
||||
return this.searchQuery !== '' ? 'show' : '';
|
||||
},
|
||||
hasLastPage() {
|
||||
return !!Math.ceil(this.totalCount / this.pageSize);
|
||||
},
|
||||
hasFirstPage() {
|
||||
return this.currentPage === 1;
|
||||
},
|
||||
hasNextPage() {
|
||||
return this.currentPage === Math.ceil(this.totalCount / this.pageSize);
|
||||
},
|
||||
hasPrevPage() {
|
||||
return this.currentPage === 1;
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 25,
|
||||
},
|
||||
methods: {
|
||||
onNextPage() {
|
||||
if (this.hasNextPage) {
|
||||
return;
|
||||
}
|
||||
const newPage = this.currentPage + 1;
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onPrevPage() {
|
||||
if (this.hasPrevPage) {
|
||||
return;
|
||||
}
|
||||
const newPage = this.currentPage - 1;
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onFirstPage() {
|
||||
if (this.hasFirstPage) {
|
||||
return;
|
||||
}
|
||||
const newPage = 1;
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onLastPage() {
|
||||
if (this.hasLastPage) {
|
||||
return;
|
||||
}
|
||||
const newPage = Math.ceil(this.totalCount / this.pageSize);
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onPageChange(page) {
|
||||
this.$emit('page-change', page);
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
const totalPages = computed(() => Math.ceil(props.totalCount / props.pageSize));
|
||||
const firstIndex = computed(() => props.pageSize * (props.currentPage - 1) + 1);
|
||||
const lastIndex = computed(() =>
|
||||
Math.min(props.totalCount, props.pageSize * props.currentPage)
|
||||
);
|
||||
const isFooterVisible = computed(
|
||||
() => props.totalCount && !(firstIndex.value > props.totalCount)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.goto-first,
|
||||
.goto-last {
|
||||
i:last-child {
|
||||
@apply -ml-1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="flex items-center bg-slate-50 dark:bg-slate-800 h-8 rounded-lg">
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasFirstPage"
|
||||
class-names="dark:!bg-slate-800 !opacity-100 ltr:rounded-l-lg ltr:rounded-r-none rtl:rounded-r-lg rtl:rounded-l-none"
|
||||
:class="buttonClass(hasFirstPage)"
|
||||
@click="onFirstPage"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevrons-left"
|
||||
size="20"
|
||||
icon-lib="lucide"
|
||||
:class="hasFirstPage && 'opacity-40'"
|
||||
/>
|
||||
</woot-button>
|
||||
<div class="bg-slate-75 dark:bg-slate-700/50 w-px rounded-sm h-4" />
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasPrevPage"
|
||||
class-names="dark:!bg-slate-800 !opacity-100 rounded-none"
|
||||
:class="buttonClass(hasPrevPage)"
|
||||
@click="onPrevPage"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevron-left-single"
|
||||
size="20"
|
||||
icon-lib="lucide"
|
||||
:class="hasPrevPage && 'opacity-40'"
|
||||
/>
|
||||
</woot-button>
|
||||
|
||||
<div
|
||||
class="flex px-3 items-center gap-3 tabular-nums bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-100"
|
||||
>
|
||||
<span class="text-sm text-slate-800 dark:text-slate-75">
|
||||
{{ currentPage }}
|
||||
</span>
|
||||
<span class="text-slate-600 dark:text-slate-500">/</span>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-500">
|
||||
{{ totalPages }}
|
||||
</span>
|
||||
</div>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasNextPage"
|
||||
class-names="dark:!bg-slate-800 !opacity-100 rounded-none"
|
||||
:class="buttonClass(hasNextPage)"
|
||||
@click="onNextPage"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevron-right-single"
|
||||
size="20"
|
||||
icon-lib="lucide"
|
||||
:class="hasNextPage && 'opacity-40'"
|
||||
/>
|
||||
</woot-button>
|
||||
<div class="bg-slate-75 dark:bg-slate-700/50 w-px rounded-sm h-4" />
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
class-names="dark:!bg-slate-800 !opacity-100 ltr:rounded-r-lg ltr:rounded-l-none rtl:rounded-l-lg rtl:rounded-r-none"
|
||||
:class="buttonClass(hasLastPage)"
|
||||
:is-disabled="hasLastPage"
|
||||
@click="onLastPage"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevrons-right"
|
||||
size="20"
|
||||
icon-lib="lucide"
|
||||
:class="hasLastPage && 'opacity-40'"
|
||||
/>
|
||||
</woot-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const hasLastPage = computed(
|
||||
() => props.currentPage === props.totalPages || props.totalPages === 1
|
||||
);
|
||||
const hasFirstPage = computed(() => props.currentPage === 1);
|
||||
const hasNextPage = computed(() => props.currentPage === props.totalPages);
|
||||
const hasPrevPage = computed(() => props.currentPage === 1);
|
||||
|
||||
const emit = defineEmits(['page-change']);
|
||||
|
||||
function buttonClass(hasPage) {
|
||||
if (hasPage) {
|
||||
return 'hover:!bg-slate-50 dark:hover:!bg-slate-800';
|
||||
}
|
||||
return 'dark:hover:!bg-slate-700/50';
|
||||
}
|
||||
|
||||
function onPageChange(newPage) {
|
||||
emit('page-change', newPage);
|
||||
}
|
||||
|
||||
const onNextPage = () => {
|
||||
if (!onNextPage.value) {
|
||||
onPageChange(props.currentPage + 1);
|
||||
}
|
||||
};
|
||||
const onPrevPage = () => {
|
||||
if (!hasPrevPage.value) {
|
||||
onPageChange(props.currentPage - 1);
|
||||
}
|
||||
};
|
||||
const onFirstPage = () => {
|
||||
if (!hasFirstPage.value) {
|
||||
onPageChange(1);
|
||||
}
|
||||
};
|
||||
const onLastPage = () => {
|
||||
if (!hasLastPage.value) {
|
||||
onPageChange(props.totalPages);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200 font-medium">
|
||||
{{
|
||||
$t('GENERAL.SHOWING_RESULTS', {
|
||||
firstIndex,
|
||||
lastIndex,
|
||||
totalCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
firstIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
lastIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
span: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const spanClass = computed(() => {
|
||||
if (props.span === 1) return 'col-span-1';
|
||||
if (props.span === 2) return 'col-span-2';
|
||||
if (props.span === 3) return 'col-span-3';
|
||||
if (props.span === 4) return 'col-span-4';
|
||||
if (props.span === 5) return 'col-span-5';
|
||||
if (props.span === 6) return 'col-span-6';
|
||||
if (props.span === 7) return 'col-span-7';
|
||||
if (props.span === 8) return 'col-span-8';
|
||||
if (props.span === 9) return 'col-span-9';
|
||||
if (props.span === 10) return 'col-span-10';
|
||||
|
||||
return 'col-span-1';
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center px-0 py-2 text-xs font-medium text-left uppercase text-slate-700 dark:text-slate-100 rtl:text-right"
|
||||
:class="spanClass"
|
||||
>
|
||||
<slot>
|
||||
{{ label }}
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -86,7 +86,11 @@
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<card-labels :conversation-id="chat.id" />
|
||||
<card-labels :conversation-id="chat.id" class="mt-0.5 mx-2 mb-0">
|
||||
<template v-if="hasSlaPolicyId" #before>
|
||||
<SLA-card-label :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||
</template>
|
||||
</card-labels>
|
||||
</div>
|
||||
<woot-context-menu
|
||||
v-if="showContextMenu"
|
||||
@@ -125,6 +129,7 @@ import alertMixin from 'shared/mixins/alertMixin';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import PriorityMark from './PriorityMark.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -135,6 +140,7 @@ export default {
|
||||
TimeAgo,
|
||||
MessagePreview,
|
||||
PriorityMark,
|
||||
SLACardLabel,
|
||||
},
|
||||
|
||||
mixins: [inboxMixin, timeMixin, conversationMixin, alertMixin],
|
||||
@@ -252,6 +258,9 @@ export default {
|
||||
const stateInbox = this.inbox;
|
||||
return stateInbox.name || '';
|
||||
},
|
||||
hasSlaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCardClick(e) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row"
|
||||
class="flex flex-col items-center justify-between px-4 py-2 bg-white border-b dark:bg-slate-900 border-slate-50 dark:border-slate-800/50 md:flex-row"
|
||||
>
|
||||
<div
|
||||
class="flex-1 w-full min-w-0 flex flex-col items-center justify-center"
|
||||
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
|
||||
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
|
||||
>
|
||||
<div class="flex justify-start items-center min-w-0 w-fit max-w-full">
|
||||
<div class="flex items-center justify-start max-w-full min-w-0 w-fit">
|
||||
<back-button
|
||||
v-if="showBackButton"
|
||||
:back-url="backButtonUrl"
|
||||
@@ -19,10 +19,10 @@
|
||||
:status="currentContact.availability_status"
|
||||
/>
|
||||
<div
|
||||
class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-fit overflow-hidden"
|
||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
|
||||
>
|
||||
<div
|
||||
class="flex items-center flex-row gap-1 m-0 p-0 w-fit max-w-full"
|
||||
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
|
||||
>
|
||||
<woot-button
|
||||
variant="link"
|
||||
@@ -31,7 +31,7 @@
|
||||
@click.prevent="$emit('contact-panel-toggle')"
|
||||
>
|
||||
<span
|
||||
class="text-base leading-tight font-medium text-slate-900 dark:text-slate-100"
|
||||
class="text-base font-medium leading-tight text-slate-900 dark:text-slate-100"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</span>
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="conversation--header--actions items-center flex text-xs gap-2 text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<inbox-name v-if="hasMultipleInboxes" :inbox="inbox" />
|
||||
<span
|
||||
@@ -67,9 +67,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0"
|
||||
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
|
||||
:class="{ 'justify-end': isContactPanelOpen }"
|
||||
>
|
||||
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
|
||||
<more-actions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,6 +86,7 @@ import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import MoreActions from './MoreActions.vue';
|
||||
import Thumbnail from '../Thumbnail.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
@@ -95,6 +97,7 @@ export default {
|
||||
InboxName,
|
||||
MoreActions,
|
||||
Thumbnail,
|
||||
SLACardLabel,
|
||||
},
|
||||
mixins: [inboxMixin, agentMixin, eventListenerMixins],
|
||||
props: {
|
||||
@@ -173,6 +176,9 @@ export default {
|
||||
hasMultipleInboxes() {
|
||||
return this.$store.getters['inboxes/getInboxes'].length > 1;
|
||||
},
|
||||
hasSlaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mapUrl = computed(
|
||||
() => `https://maps.google.com/?q=${props.latitude},${props.longitude}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="location message-text__wrap">
|
||||
<div class="icon-wrap">
|
||||
<fluent-icon icon="location" class="file--icon" size="32" />
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div
|
||||
class="flex flex-row items-center justify-start gap-1 w-full py-1 px-0 cursor-pointer overflow-hidden"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="location"
|
||||
class="text-slate-600 dark:text-slate-200 leading-none my-0 flex items-center flex-shrink-0"
|
||||
size="32"
|
||||
/>
|
||||
<div class="flex flex-col items-start flex-1 min-w-0">
|
||||
<h5
|
||||
class="text-sm text-slate-800 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
class="text-sm text-slate-800 dark:text-slate-100 truncate m-0 w-full"
|
||||
:title="name"
|
||||
>
|
||||
{{ name }}
|
||||
</h5>
|
||||
<div class="link-wrap">
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
class="download clear link button small"
|
||||
class="text-woot-600 dark:text-woot-600 text-xs underline"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
:href="mapUrl"
|
||||
@@ -22,49 +50,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
mapUrl() {
|
||||
return `https://maps.google.com/?q=${this.latitude},${this.longitude}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.location {
|
||||
@apply flex flex-row py-1 px-0 cursor-pointer;
|
||||
|
||||
.icon-wrap {
|
||||
@apply text-slate-600 dark:text-slate-200 leading-none my-0 mx-1;
|
||||
}
|
||||
|
||||
.text-block-title {
|
||||
@apply m-0 text-slate-800 dark:text-slate-100 break-words;
|
||||
}
|
||||
|
||||
.meta {
|
||||
@apply flex flex-col items-center pr-4;
|
||||
}
|
||||
|
||||
.link-wrap {
|
||||
@apply flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
}"
|
||||
>
|
||||
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" />
|
||||
<letter
|
||||
v-else
|
||||
class="text-content bg-white dark:bg-white text-slate-900 dark:text-slate-900 p-2 rounded-[4px]"
|
||||
:html="message"
|
||||
/>
|
||||
<div v-else @click="handleClickOnContent">
|
||||
<letter
|
||||
class="text-content bg-white dark:bg-white text-slate-900 dark:text-slate-900 p-2 rounded-[4px]"
|
||||
:html="message"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="showQuoteToggle"
|
||||
class="text-slate-300 dark:text-slate-300 cursor-pointer text-xs py-1"
|
||||
@@ -26,14 +27,23 @@
|
||||
{{ $t('CHAT_LIST.SHOW_QUOTED_TEXT') }}
|
||||
</span>
|
||||
</button>
|
||||
<gallery-view
|
||||
v-if="showGalleryViewer"
|
||||
:show.sync="showGalleryViewer"
|
||||
:attachment="attachment"
|
||||
:all-attachments="availableAttachments"
|
||||
@error="onClose"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Letter from 'vue-letter';
|
||||
import GalleryView from '../components/GalleryView.vue';
|
||||
|
||||
export default {
|
||||
components: { Letter },
|
||||
components: { Letter, GalleryView },
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
@@ -51,6 +61,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showQuotedContent: false,
|
||||
showGalleryViewer: false,
|
||||
attachment: {},
|
||||
availableAttachments: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -71,6 +84,33 @@ export default {
|
||||
toggleQuotedContent() {
|
||||
this.showQuotedContent = !this.showQuotedContent;
|
||||
},
|
||||
handleClickOnContent(event) {
|
||||
// if event target is IMG and not close in A tag
|
||||
// then open image preview
|
||||
const isImageElement = event.target.tagName === 'IMG';
|
||||
const isWrappedInLink = event.target.closest('A');
|
||||
|
||||
if (isImageElement && !isWrappedInLink) {
|
||||
this.openImagePreview(event.target.src);
|
||||
}
|
||||
},
|
||||
openImagePreview(src) {
|
||||
this.showGalleryViewer = true;
|
||||
this.attachment = {
|
||||
file_type: 'image',
|
||||
data_url: src,
|
||||
message_id: Math.floor(Math.random() * 100),
|
||||
};
|
||||
this.availableAttachments = [{ ...this.attachment }];
|
||||
},
|
||||
onClose() {
|
||||
this.showGalleryViewer = false;
|
||||
this.resetAttachmentData();
|
||||
},
|
||||
resetAttachmentData() {
|
||||
this.attachment = {};
|
||||
this.availableAttachments = [];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -82,6 +122,7 @@ export default {
|
||||
ol {
|
||||
padding-left: var(--space-two);
|
||||
}
|
||||
|
||||
table {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
|
||||
+32
-22
@@ -8,12 +8,19 @@
|
||||
>
|
||||
<div
|
||||
v-on-clickaway="onClose"
|
||||
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit]"
|
||||
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
|
||||
@click="onClose"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full h-16 px-6 py-2">
|
||||
<div class="items-center flex justify-start min-w-[15rem]" @click.stop>
|
||||
<div
|
||||
class="bg-white dark:bg-slate-900 z-10 flex items-center justify-between w-full h-16 px-6 py-2"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-if="senderDetails"
|
||||
class="items-center flex justify-start min-w-[15rem]"
|
||||
>
|
||||
<thumbnail
|
||||
v-if="senderDetails.avatar"
|
||||
:username="senderDetails.name"
|
||||
:src="senderDetails.avatar"
|
||||
/>
|
||||
@@ -38,14 +45,12 @@
|
||||
class="flex items-center justify-start w-auto min-w-0 p-1 text-sm font-semibold text-slate-700 dark:text-slate-100"
|
||||
>
|
||||
<span
|
||||
v-dompurify-html="fileNameFromDataUrl"
|
||||
class="overflow-hidden text-slate-700 dark:text-slate-100 whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ fileNameFromDataUrl }}
|
||||
</span>
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]"
|
||||
@click.stop
|
||||
>
|
||||
<woot-button
|
||||
v-if="isImage"
|
||||
@@ -99,6 +104,7 @@
|
||||
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
|
||||
<woot-button
|
||||
v-if="hasMoreThanOneAttachment"
|
||||
class="z-10"
|
||||
size="large"
|
||||
variant="smooth"
|
||||
color-scheme="primary"
|
||||
@@ -118,7 +124,7 @@
|
||||
v-if="isImage"
|
||||
:key="activeAttachment.message_id"
|
||||
:src="activeAttachment.data_url"
|
||||
class="mx-auto my-0 modal-image skip-context-menu"
|
||||
class="mx-auto my-0 duration-150 ease-in-out transform modal-image skip-context-menu"
|
||||
:style="imageRotationStyle"
|
||||
@click.stop="onClickZoomImage"
|
||||
@wheel.stop="onWheelImageZoom"
|
||||
@@ -146,6 +152,7 @@
|
||||
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
|
||||
<woot-button
|
||||
v-if="hasMoreThanOneAttachment"
|
||||
class="z-10"
|
||||
size="large"
|
||||
variant="smooth"
|
||||
color-scheme="primary"
|
||||
@@ -160,7 +167,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center w-full h-16 px-6 py-2">
|
||||
<div class="flex items-center justify-center w-full h-16 px-6 py-2 z-10">
|
||||
<div
|
||||
class="items-center rounded-sm flex font-semibold justify-center min-w-[5rem] p-1 bg-slate-25 dark:bg-slate-800 text-slate-600 dark:text-slate-200 text-sm"
|
||||
>
|
||||
@@ -233,12 +240,9 @@ export default {
|
||||
return this.allAttachments.length > 1;
|
||||
},
|
||||
readableTime() {
|
||||
if (!this.activeAttachment.created_at) return '';
|
||||
const time = this.messageTimestamp(
|
||||
this.activeAttachment.created_at,
|
||||
'LLL d yyyy, h:mm a'
|
||||
);
|
||||
return time || '';
|
||||
const { created_at: createdAt } = this.activeAttachment;
|
||||
if (!createdAt) return '';
|
||||
return this.messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
|
||||
},
|
||||
isImage() {
|
||||
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
|
||||
@@ -267,7 +271,7 @@ export default {
|
||||
const { data_url: dataUrl } = this.activeAttachment;
|
||||
if (!dataUrl) return '';
|
||||
const fileName = dataUrl?.split('/').pop();
|
||||
return fileName || '';
|
||||
return decodeURIComponent(fileName || '');
|
||||
},
|
||||
imageRotationStyle() {
|
||||
return {
|
||||
@@ -290,6 +294,7 @@ export default {
|
||||
this.activeImageIndex = index;
|
||||
this.setImageAndVideoSrc(attachment);
|
||||
this.activeImageRotation = 0;
|
||||
this.zoomScale = 1;
|
||||
},
|
||||
setImageAndVideoSrc(attachment) {
|
||||
const { file_type: type } = attachment;
|
||||
@@ -346,17 +351,22 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scale > 0 && this.zoomScale >= MAX_ZOOM_LEVEL) {
|
||||
this.zoomScale = 1;
|
||||
const newZoomScale = this.zoomScale + scale;
|
||||
// Check if the new zoom scale is within the allowed range
|
||||
if (newZoomScale > MAX_ZOOM_LEVEL) {
|
||||
// Set zoom to max but do not reset to default
|
||||
this.zoomScale = MAX_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
|
||||
if (scale < 0 && this.zoomScale <= MIN_ZOOM_LEVEL) {
|
||||
this.zoomScale = 1;
|
||||
if (newZoomScale < MIN_ZOOM_LEVEL) {
|
||||
// Set zoom to min but do not reset to default
|
||||
this.zoomScale = MIN_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
this.zoomScale += scale;
|
||||
// If within bounds, update the zoom scale
|
||||
this.zoomScale = newZoomScale;
|
||||
},
|
||||
|
||||
onWheelImageZoom(e) {
|
||||
if (!this.isImage) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasSlaThreshold"
|
||||
class="relative flex items-center border cursor-pointer min-w-fit border-slate-100 dark:border-slate-700"
|
||||
:class="showExtendedInfo ? 'h-[26px] rounded-lg' : 'rounded h-5'"
|
||||
>
|
||||
<div
|
||||
v-on-clickaway="closeSlaPopover"
|
||||
class="flex items-center w-full truncate"
|
||||
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
|
||||
@mouseover="openSlaPopover()"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
:class="
|
||||
showExtendedInfo &&
|
||||
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-solid border-slate-100 dark:border-slate-700'
|
||||
"
|
||||
>
|
||||
<fluent-icon
|
||||
size="14"
|
||||
:icon="slaStatus.icon"
|
||||
type="outline"
|
||||
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
|
||||
class="flex-shrink-0"
|
||||
:class="slaTextStyles"
|
||||
/>
|
||||
<span
|
||||
v-if="showExtendedInfo"
|
||||
class="text-xs font-medium"
|
||||
:class="slaTextStyles"
|
||||
>
|
||||
{{ slaStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium"
|
||||
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
|
||||
>
|
||||
{{ slaStatus.threshold }}
|
||||
</span>
|
||||
</div>
|
||||
<SLA-popover-card
|
||||
v-if="showSlaPopoverCard"
|
||||
:sla-missed-events="slaEvents"
|
||||
class="right-0 top-7"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { evaluateSLAStatus } from '../helpers/SLAHelper';
|
||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SLAPopoverCard,
|
||||
},
|
||||
mixins: [clickaway],
|
||||
props: {
|
||||
chat: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
showExtendedInfo: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
timer: null,
|
||||
showSlaPopover: false,
|
||||
slaStatus: {
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
slaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
appliedSLA() {
|
||||
return this.chat?.applied_sla;
|
||||
},
|
||||
slaEvents() {
|
||||
return this.chat?.sla_events;
|
||||
},
|
||||
hasSlaThreshold() {
|
||||
return this.slaStatus?.threshold;
|
||||
},
|
||||
isSlaMissed() {
|
||||
return this.slaStatus?.isSlaMissed;
|
||||
},
|
||||
slaTextStyles() {
|
||||
return this.isSlaMissed
|
||||
? 'text-red-400 dark:text-red-300'
|
||||
: 'text-yellow-600 dark:text-yellow-500';
|
||||
},
|
||||
slaStatusText() {
|
||||
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
|
||||
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
|
||||
|
||||
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
|
||||
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
|
||||
});
|
||||
},
|
||||
showSlaPopoverCard() {
|
||||
return (
|
||||
this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
chat() {
|
||||
this.updateSlaStatus();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updateSlaStatus();
|
||||
this.createTimer();
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createTimer() {
|
||||
this.timer = setTimeout(() => {
|
||||
this.updateSlaStatus();
|
||||
this.createTimer();
|
||||
}, REFRESH_INTERVAL);
|
||||
},
|
||||
updateSlaStatus() {
|
||||
this.slaStatus = evaluateSLAStatus(this.appliedSLA, this.chat);
|
||||
},
|
||||
openSlaPopover() {
|
||||
if (!this.showExtendedInfo) return;
|
||||
this.showSlaPopover = true;
|
||||
},
|
||||
closeSlaPopover() {
|
||||
this.showSlaPopover = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { format, fromUnixTime } from 'date-fns';
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const formatDate = timestamp =>
|
||||
format(fromUnixTime(timestamp), 'MMM dd, yyyy, hh:mm a');
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex justify-between w-full">
|
||||
<span
|
||||
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<span
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="text-sm font-normal text-slate-900 dark:text-slate-25 text-right tabular-nums"
|
||||
>
|
||||
{{ formatDate(item.created_at) }}
|
||||
</span>
|
||||
<slot name="showMore" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import SLAEventItem from './SLAEventItem.vue';
|
||||
|
||||
const { SLA_MISS_TYPES } = wootConstants;
|
||||
|
||||
const props = defineProps({
|
||||
slaMissedEvents: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShowAllNrts = ref(false);
|
||||
|
||||
const frtMisses = computed(() =>
|
||||
props.slaMissedEvents.filter(
|
||||
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.FRT
|
||||
)
|
||||
);
|
||||
const nrtMisses = computed(() => {
|
||||
const missedEvents = props.slaMissedEvents.filter(
|
||||
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.NRT
|
||||
);
|
||||
return shouldShowAllNrts.value ? missedEvents : missedEvents.slice(0, 6);
|
||||
});
|
||||
const rtMisses = computed(() =>
|
||||
props.slaMissedEvents.filter(
|
||||
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.RT
|
||||
)
|
||||
);
|
||||
|
||||
const shouldShowMoreNRTButton = computed(() => nrtMisses.value.length > 6);
|
||||
const toggleShowAllNRT = () => {
|
||||
shouldShowAllNrts.value = !shouldShowAllNrts.value;
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col items-start bg-[#fdfdfd] dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
>
|
||||
<span class="text-sm font-medium text-slate-900 dark:text-slate-25">
|
||||
{{ $t('SLA.EVENTS.TITLE') }}
|
||||
</span>
|
||||
<SLA-event-item
|
||||
v-if="frtMisses.length"
|
||||
:label="$t('SLA.EVENTS.FRT')"
|
||||
:items="frtMisses"
|
||||
/>
|
||||
<SLA-event-item
|
||||
v-if="nrtMisses.length"
|
||||
:label="$t('SLA.EVENTS.NRT')"
|
||||
:items="nrtMisses"
|
||||
>
|
||||
<template #showMore>
|
||||
<div
|
||||
v-if="shouldShowMoreNRTButton"
|
||||
class="flex flex-col items-end w-full"
|
||||
>
|
||||
<woot-button
|
||||
size="small"
|
||||
:icon="!shouldShowAllNrts ? 'plus-sign' : ''"
|
||||
variant="link"
|
||||
color-scheme="secondary"
|
||||
class="hover:!no-underline !gap-1 hover:!bg-transparent dark:hover:!bg-transparent"
|
||||
@click="toggleShowAllNRT"
|
||||
>
|
||||
{{
|
||||
shouldShowAllNrts
|
||||
? $t('SLA.EVENTS.HIDE', { count: nrtMisses.length })
|
||||
: $t('SLA.EVENTS.SHOW_MORE', { count: nrtMisses.length })
|
||||
}}
|
||||
</woot-button>
|
||||
</div>
|
||||
</template>
|
||||
</SLA-event-item>
|
||||
<SLA-event-item
|
||||
v-if="rtMisses.length"
|
||||
:label="$t('SLA.EVENTS.RT')"
|
||||
:items="rtMisses"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+25
-15
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="activeLabels.length"
|
||||
v-if="activeLabels.length || $slots.before"
|
||||
ref="labelContainer"
|
||||
class="label-container mt-0.5 mx-2 mb-0"
|
||||
v-resize="computeVisibleLabelPosition"
|
||||
>
|
||||
<div
|
||||
class="labels-wrap flex items-end min-w-0 flex-shrink gap-y-1 flex-wrap"
|
||||
:class="{ expand: showAllLabels }"
|
||||
class="flex items-end flex-shrink min-w-0 gap-y-1"
|
||||
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
|
||||
>
|
||||
<slot name="before" />
|
||||
<woot-label
|
||||
v-for="(label, index) in activeLabels"
|
||||
:key="label.id"
|
||||
@@ -26,7 +27,7 @@
|
||||
? $t('CONVERSATION.CARD.HIDE_LABELS')
|
||||
: $t('CONVERSATION.CARD.SHOW_LABELS')
|
||||
"
|
||||
class="show-more--button sticky flex-shrink-0 right-0 mr-6 rtl:rotate-180"
|
||||
class="sticky right-0 flex-shrink-0 mr-6 show-more--button rtl:rotate-180"
|
||||
color-scheme="secondary"
|
||||
variant="hollow"
|
||||
:icon="showAllLabels ? 'chevron-left' : 'chevron-right'"
|
||||
@@ -45,6 +46,11 @@ export default {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
conversationLabels: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -59,26 +65,34 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// the problem here is that there is a certain amount of delay between the conversation
|
||||
// card being mounted and the resize event eventually being triggered
|
||||
// This means we need to run the function immediately after the component is mounted
|
||||
// Happens especially when used in a virtual list.
|
||||
// We can make the first trigger, a standard part of the directive, in case
|
||||
// we face this issue again
|
||||
this.computeVisibleLabelPosition();
|
||||
},
|
||||
methods: {
|
||||
onShowLabels(e) {
|
||||
e.stopPropagation();
|
||||
this.showAllLabels = !this.showAllLabels;
|
||||
this.$nextTick(() => this.computeVisibleLabelPosition());
|
||||
},
|
||||
computeVisibleLabelPosition() {
|
||||
const beforeSlot = this.$slots.before ? 100 : 0;
|
||||
const labelContainer = this.$refs.labelContainer;
|
||||
const labels = this.$refs.labelContainer.querySelectorAll('.label');
|
||||
if (!labelContainer) return;
|
||||
|
||||
const labels = Array.from(labelContainer.querySelectorAll('.label'));
|
||||
let labelOffset = 0;
|
||||
this.showExpandLabelButton = false;
|
||||
|
||||
Array.from(labels).forEach((label, index) => {
|
||||
labels.forEach((label, index) => {
|
||||
labelOffset += label.offsetWidth + 8;
|
||||
|
||||
if (labelOffset < labelContainer.clientWidth - 16) {
|
||||
if (labelOffset < labelContainer.clientWidth - 16 - beforeSlot) {
|
||||
this.labelPosition = index;
|
||||
} else {
|
||||
this.showExpandLabelButton = true;
|
||||
this.showExpandLabelButton = labels.length > 1;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -95,10 +109,6 @@ export default {
|
||||
}
|
||||
|
||||
.labels-wrap {
|
||||
&.expand {
|
||||
@apply h-auto overflow-visible flex-row flex-wrap;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
@apply border border-solid border-slate-100 dark:border-slate-700;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
const calculateThreshold = (timeOffset, threshold) => {
|
||||
// Calculate the time left for the SLA to breach or the time since the SLA has missed
|
||||
if (threshold === null) return null;
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return timeOffset + threshold - currentTime;
|
||||
};
|
||||
|
||||
const findMostUrgentSLAStatus = SLAStatuses => {
|
||||
// Sort the SLAs based on the threshold and return the most urgent SLA
|
||||
SLAStatuses.sort(
|
||||
(sla1, sla2) => Math.abs(sla1.threshold) - Math.abs(sla2.threshold)
|
||||
);
|
||||
return SLAStatuses[0];
|
||||
};
|
||||
|
||||
const formatSLATime = seconds => {
|
||||
const units = {
|
||||
y: 31536000, // 60 * 60 * 24 * 365
|
||||
mo: 2592000, // 60 * 60 * 24 * 30
|
||||
d: 86400, // 60 * 60 * 24
|
||||
h: 3600, // 60 * 60
|
||||
m: 60,
|
||||
};
|
||||
|
||||
if (seconds < 60) {
|
||||
return '1m';
|
||||
}
|
||||
|
||||
// we will only show two parts, two max granularity's, h-m, y-d, d-h, m, but no seconds
|
||||
const parts = [];
|
||||
|
||||
Object.keys(units).forEach(unit => {
|
||||
const value = Math.floor(seconds / units[unit]);
|
||||
if (seconds < 60 && parts.length > 0) return;
|
||||
if (parts.length === 2) return;
|
||||
if (value > 0) {
|
||||
parts.push(value + unit);
|
||||
seconds -= value * units[unit];
|
||||
}
|
||||
});
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const createSLAObject = (
|
||||
type,
|
||||
{
|
||||
sla_first_response_time_threshold: frtThreshold,
|
||||
sla_next_response_time_threshold: nrtThreshold,
|
||||
sla_resolution_time_threshold: rtThreshold,
|
||||
created_at: createdAt,
|
||||
} = {},
|
||||
{
|
||||
first_reply_created_at: firstReplyCreatedAt,
|
||||
waiting_since: waitingSince,
|
||||
status,
|
||||
} = {}
|
||||
) => {
|
||||
// Mapping of breach types to their logic
|
||||
const SLATypes = {
|
||||
FRT: {
|
||||
threshold: calculateThreshold(createdAt, frtThreshold),
|
||||
// Check FRT only if threshold is not null and first reply hasn't been made
|
||||
condition:
|
||||
frtThreshold !== null &&
|
||||
(!firstReplyCreatedAt || firstReplyCreatedAt === 0),
|
||||
},
|
||||
NRT: {
|
||||
threshold: calculateThreshold(waitingSince, nrtThreshold),
|
||||
// Check NRT only if threshold is not null, first reply has been made and we are waiting since
|
||||
condition:
|
||||
nrtThreshold !== null && !!firstReplyCreatedAt && !!waitingSince,
|
||||
},
|
||||
RT: {
|
||||
threshold: calculateThreshold(createdAt, rtThreshold),
|
||||
// Check RT only if the conversation is open and threshold is not null
|
||||
condition: status === 'open' && rtThreshold !== null,
|
||||
},
|
||||
};
|
||||
|
||||
const SLAStatus = SLATypes[type];
|
||||
return SLAStatus ? { ...SLAStatus, type } : null;
|
||||
};
|
||||
|
||||
const evaluateSLAConditions = (appliedSla, chat) => {
|
||||
// Filter out the SLA based on conditions and update the object with the breach status(icon, isSlaMissed)
|
||||
const SLATypes = ['FRT', 'NRT', 'RT'];
|
||||
return SLATypes.map(type => createSLAObject(type, appliedSla, chat))
|
||||
.filter(SLAStatus => SLAStatus && SLAStatus.condition)
|
||||
.map(SLAStatus => ({
|
||||
...SLAStatus,
|
||||
icon: SLAStatus.threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: SLAStatus.threshold <= 0,
|
||||
}));
|
||||
};
|
||||
|
||||
export const evaluateSLAStatus = (appliedSla, chat) => {
|
||||
if (!appliedSla || !chat)
|
||||
return { type: '', threshold: '', icon: '', isSlaMissed: false };
|
||||
|
||||
// Filter out the SLA and create the object for each breach
|
||||
const SLAStatuses = evaluateSLAConditions(appliedSla, chat);
|
||||
|
||||
// Return the most urgent SLA which is latest to breach or has missed
|
||||
const mostUrgent = findMostUrgentSLAStatus(SLAStatuses);
|
||||
return mostUrgent
|
||||
? {
|
||||
type: mostUrgent.type,
|
||||
threshold: formatSLATime(
|
||||
mostUrgent.threshold <= 0
|
||||
? -mostUrgent.threshold
|
||||
: mostUrgent.threshold
|
||||
),
|
||||
icon: mostUrgent.icon,
|
||||
isSlaMissed: mostUrgent.isSlaMissed,
|
||||
}
|
||||
: { type: '', threshold: '', icon: '', isSlaMissed: false };
|
||||
};
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { evaluateSLAStatus } from '../SLAHelper';
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => new Date('2024-01-01T00:00:00Z').getTime());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('SLAHelper', () => {
|
||||
describe('evaluateSLAStatus', () => {
|
||||
it('returns an empty object when sla or chat is not present', () => {
|
||||
expect(evaluateSLAStatus(null, null)).toEqual({
|
||||
type: '',
|
||||
threshold: '',
|
||||
icon: '',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when FRT SLA is missed
|
||||
it('correctly identifies a missed FRT SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704066540,
|
||||
};
|
||||
const chatMissed = {
|
||||
first_reply_created_at: 0,
|
||||
waiting_since: 0,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
|
||||
type: 'FRT',
|
||||
threshold: '1m',
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when FRT SLA is not missed
|
||||
it('correctly identifies an FRT SLA not yet breached', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704066660,
|
||||
};
|
||||
const chatNotMissed = {
|
||||
first_reply_created_at: 0,
|
||||
waiting_since: 0,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
|
||||
type: 'FRT',
|
||||
threshold: '1m',
|
||||
icon: 'alarm',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when NRT SLA is missed
|
||||
it('correctly identifies a missed NRT SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704065200,
|
||||
};
|
||||
const chatMissed = {
|
||||
first_reply_created_at: 1704066200,
|
||||
waiting_since: 1704065940,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
|
||||
type: 'NRT',
|
||||
threshold: '1m',
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when NRT SLA is not missed
|
||||
it('correctly identifies an NRT SLA not yet breached', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704065200 - 2000,
|
||||
};
|
||||
const chatNotMissed = {
|
||||
first_reply_created_at: 1704066200,
|
||||
waiting_since: 1704066060,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
|
||||
type: 'NRT',
|
||||
threshold: '1m',
|
||||
icon: 'alarm',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when RT SLA is missed
|
||||
it('correctly identifies a missed RT SLA', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704065340,
|
||||
};
|
||||
const chatMissed = {
|
||||
first_reply_created_at: 1704066200,
|
||||
waiting_since: 0,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
|
||||
type: 'RT',
|
||||
threshold: '1m',
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Case when RT SLA is not missed
|
||||
it('correctly identifies an RT SLA not yet breached', () => {
|
||||
const appliedSla = {
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 1200,
|
||||
sla_resolution_time_threshold: 1800,
|
||||
created_at: 1704065460,
|
||||
};
|
||||
const chatNotMissed = {
|
||||
first_reply_created_at: 1704066200,
|
||||
waiting_since: 0,
|
||||
status: 'open',
|
||||
};
|
||||
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
|
||||
type: 'RT',
|
||||
threshold: '1m',
|
||||
icon: 'alarm',
|
||||
isSlaMissed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,5 +59,10 @@ export default {
|
||||
TYPE: 'type',
|
||||
SORT_ORDER: 'sort_order',
|
||||
},
|
||||
SLA_MISS_TYPES: {
|
||||
FRT: 'frt',
|
||||
NRT: 'nrt',
|
||||
RT: 'rt',
|
||||
},
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
|
||||
@@ -19,4 +19,15 @@ export const FEATURE_FLAGS = {
|
||||
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
|
||||
INBOX_VIEW: 'inbox_view',
|
||||
SLA: 'sla',
|
||||
RESPONSE_BOT: 'response_bot',
|
||||
CHANNEL_EMAIL: 'channel_email',
|
||||
CHANNEL_FACEBOOK: 'channel_facebook',
|
||||
CHANNEL_TWITTER: 'channel_twitter',
|
||||
CHANNEL_WEBSITE: 'channel_website',
|
||||
CUSTOM_REPLY_DOMAIN: 'custom_reply_domain',
|
||||
CUSTOM_REPLY_EMAIL: 'custom_reply_email',
|
||||
DISABLE_BRANDING: 'disable_branding',
|
||||
EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel',
|
||||
INBOUND_EMAILS: 'inbound_emails',
|
||||
IP_LOOKUP: 'ip_lookup',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
|
||||
const RESIZE_OBSERVER_DEBOUNCE_TIME = 100;
|
||||
|
||||
function createResizeObserver(el, binding) {
|
||||
const { value } = binding;
|
||||
const observer = new ResizeObserver(
|
||||
debounce(entries => {
|
||||
const entry = entries[0];
|
||||
if (entry && value && typeof value === 'function') {
|
||||
value(entry);
|
||||
}
|
||||
}, RESIZE_OBSERVER_DEBOUNCE_TIME)
|
||||
);
|
||||
|
||||
el.cwResizeObserver = observer;
|
||||
observer.observe(el);
|
||||
}
|
||||
|
||||
function destroyResizeObserver(el) {
|
||||
if (el.cwResizeObserver) {
|
||||
el.cwResizeObserver.unobserve(el);
|
||||
el.cwResizeObserver.disconnect();
|
||||
delete el.cwResizeObserver;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
bind(el, binding) {
|
||||
createResizeObserver(el, binding);
|
||||
},
|
||||
update(el, binding) {
|
||||
if (binding.oldValue !== binding.value) {
|
||||
destroyResizeObserver(el);
|
||||
createResizeObserver(el, binding);
|
||||
}
|
||||
},
|
||||
unbind(el) {
|
||||
destroyResizeObserver(el);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
const { helpUrls } = window.chatwootConfig;
|
||||
return helpUrls[featureName];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import resize from '../../directives/resize';
|
||||
|
||||
class ResizeObserverMock {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
observe() {}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
unobserve() {}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
describe('resize directive', () => {
|
||||
let el;
|
||||
let binding;
|
||||
let observer;
|
||||
|
||||
beforeEach(() => {
|
||||
el = document.createElement('div');
|
||||
binding = {
|
||||
value: jest.fn(),
|
||||
};
|
||||
observer = {
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
window.ResizeObserver = ResizeObserverMock;
|
||||
jest.spyOn(window, 'ResizeObserver').mockImplementation(() => observer);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create ResizeObserver on bind', () => {
|
||||
resize.bind(el, binding);
|
||||
|
||||
expect(ResizeObserver).toHaveBeenCalled();
|
||||
expect(observer.observe).toHaveBeenCalledWith(el);
|
||||
});
|
||||
|
||||
it('should call callback on observer callback', () => {
|
||||
el = document.createElement('div');
|
||||
binding = {
|
||||
value: jest.fn(),
|
||||
};
|
||||
|
||||
resize.bind(el, binding);
|
||||
|
||||
const entries = [{ contentRect: { width: 100, height: 100 } }];
|
||||
const callback = binding.value;
|
||||
callback(entries[0]);
|
||||
|
||||
expect(binding.value).toHaveBeenCalledWith(entries[0]);
|
||||
});
|
||||
|
||||
it('should destroy and recreate observer on update', () => {
|
||||
resize.bind(el, binding);
|
||||
|
||||
resize.update(el, { ...binding, oldValue: 'old' });
|
||||
|
||||
expect(observer.unobserve).toHaveBeenCalledWith(el);
|
||||
expect(observer.disconnect).toHaveBeenCalled();
|
||||
expect(ResizeObserver).toHaveBeenCalledTimes(2);
|
||||
expect(observer.observe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should destroy observer on unbind', () => {
|
||||
resize.bind(el, binding);
|
||||
|
||||
resize.unbind(el);
|
||||
|
||||
expect(observer.unobserve).toHaveBeenCalledWith(el);
|
||||
expect(observer.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -296,6 +296,8 @@
|
||||
"BUTTON": "Add custom attribute",
|
||||
"NOT_AVAILABLE": "There are no custom attributes available for this contact.",
|
||||
"COPY_SUCCESSFUL": "Copied to clipboard successfully",
|
||||
"SHOW_MORE": "Show all attributes",
|
||||
"SHOW_LESS": "Show less attributes",
|
||||
"ACTIONS": {
|
||||
"COPY": "Copy attribute",
|
||||
"DELETE": "Delete attribute",
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
|
||||
"CREATED_AT": "Created At",
|
||||
"LAST_ACTIVITY": "Last Activity",
|
||||
"REFERER_LINK": "Referrer link"
|
||||
"REFERER_LINK": "Referrer link",
|
||||
"BLOCKED": "Blocked"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard Filters",
|
||||
|
||||
@@ -64,7 +64,14 @@
|
||||
"SNOOZED_UNTIL": "Snoozed until",
|
||||
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
||||
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply"
|
||||
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
|
||||
"SLA_STATUS": {
|
||||
"FRT": "FRT {status}",
|
||||
"NRT": "NRT {status}",
|
||||
"RT": "RT {status}",
|
||||
"MISSED": "missed",
|
||||
"DUE": "due"
|
||||
}
|
||||
},
|
||||
"RESOLVE_DROPDOWN": {
|
||||
"MARK_PENDING": "Mark as pending",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"GENERAL": {
|
||||
"SHOWING_RESULTS": "Showing {firstIndex}-{lastIndex} of {totalCount} items"
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,10 @@
|
||||
"conversation_assignment": "Conversation Assigned",
|
||||
"assigned_conversation_new_message": "New Message",
|
||||
"participating_conversation_new_message": "New Message",
|
||||
"conversation_mention": "Mention"
|
||||
"conversation_mention": "Mention",
|
||||
"sla_missed_first_response": "SLA Missed",
|
||||
"sla_missed_next_response": "SLA Missed",
|
||||
"sla_missed_resolution": "SLA Missed"
|
||||
}
|
||||
},
|
||||
"NETWORK": {
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
"CONVERSATION_CREATION": "New conversation created",
|
||||
"CONVERSATION_ASSIGNMENT": "A conversation has been assigned to you",
|
||||
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "New message in an assigned conversation",
|
||||
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "New message in a conversation you are participating in"
|
||||
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "New message in a conversation you are participating in",
|
||||
"SLA_MISSED_FIRST_RESPONSE": "SLA target first response missed for conversation",
|
||||
"SLA_MISSED_NEXT_RESPONSE": "SLA target next response missed for conversation",
|
||||
"SLA_MISSED_RESOLUTION": "SLA target resolution missed for conversation"
|
||||
},
|
||||
"MENU_ITEM": {
|
||||
"MARK_AS_READ": "Mark as read",
|
||||
|
||||
@@ -31,6 +31,7 @@ import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import sla from './sla.json';
|
||||
import inbox from './inbox.json';
|
||||
import general from './general.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
@@ -66,4 +67,5 @@ export default {
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...inbox,
|
||||
...general,
|
||||
};
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
"NAME": "Resolution Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_RESOLUTION_COUNT": {
|
||||
"NAME": "Resolution Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_HANDOFF_COUNT": {
|
||||
"NAME": "Handoff Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"NAME": "Customer waiting time",
|
||||
"TOOLTIP_TEXT": "Waiting time is %{metricValue} (based on %{conversationCount} replies)"
|
||||
@@ -86,22 +94,55 @@
|
||||
"MONTH": "Month",
|
||||
"YEAR": "Year"
|
||||
},
|
||||
"GROUP_BY_DAY_OPTIONS": [{ "id": 1, "groupBy": "Day" }],
|
||||
"GROUP_BY_DAY_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_WEEK_OPTIONS": [
|
||||
{ "id": 1, "groupBy": "Day" },
|
||||
{ "id": 2, "groupBy": "Week" }
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "Week"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_MONTH_OPTIONS": [
|
||||
{ "id": 1, "groupBy": "Day" },
|
||||
{ "id": 2, "groupBy": "Week" },
|
||||
{ "id": 3, "groupBy": "Month" }
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "Week"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"groupBy": "Month"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_YEAR_OPTIONS": [
|
||||
{ "id": 2, "groupBy": "Week" },
|
||||
{ "id": 3, "groupBy": "Month" },
|
||||
{ "id": 4, "groupBy": "Year" }
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "Week"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"groupBy": "Month"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"groupBy": "Year"
|
||||
}
|
||||
],
|
||||
"BUSINESS_HOURS": "Business Hours"
|
||||
"BUSINESS_HOURS": "Business Hours",
|
||||
"FILTER_ACTIONS": {
|
||||
"CLEAR_FILTER": "Clear filter",
|
||||
"EMPTY_LIST": "No results found"
|
||||
}
|
||||
},
|
||||
"AGENT_REPORTS": {
|
||||
"HEADER": "Agents Overview",
|
||||
@@ -404,6 +445,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BOT_REPORTS": {
|
||||
"HEADER": "Bot Reports",
|
||||
"METRIC": {
|
||||
"TOTAL_CONVERSATIONS": {
|
||||
"LABEL": "No. of Conversations",
|
||||
"TOOLTIP": "Total number of conversations handled by the bot"
|
||||
},
|
||||
"TOTAL_RESPONSES": {
|
||||
"LABEL": "Total Responses",
|
||||
"TOOLTIP": "Total number of responses sent by the bot"
|
||||
},
|
||||
"RESOLUTION_RATE": {
|
||||
"LABEL": "Resolution Rate",
|
||||
"TOOLTIP": "Total number of conversations resolved by the bot / Total number of conversations handled by the bot * 100"
|
||||
},
|
||||
"HANDOFF_RATE": {
|
||||
"LABEL": "Handoff Rate",
|
||||
"TOOLTIP": "Total number of conversations handed off to agents / Total number of conversations handled by the bot * 100"
|
||||
}
|
||||
}
|
||||
},
|
||||
"OVERVIEW_REPORTS": {
|
||||
"HEADER": "Overview",
|
||||
"LIVE": "Live",
|
||||
@@ -447,5 +509,54 @@
|
||||
"THURSDAY": "Thursday",
|
||||
"FRIDAY": "Friday",
|
||||
"SATURDAY": "Saturday"
|
||||
},
|
||||
"SLA_REPORTS": {
|
||||
"HEADER": "SLA Reports",
|
||||
"NO_RECORDS": "SLA applied conversations are not available.",
|
||||
"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",
|
||||
"TOOLTIP": "Percentage of SLAs created were completed successfully"
|
||||
},
|
||||
"NO_OF_MISSES": {
|
||||
"LABEL": "Number of Misses",
|
||||
"TOOLTIP": "Total SLA misses in a certain period"
|
||||
},
|
||||
"NO_OF_CONVERSATIONS": {
|
||||
"LABEL": "Number of Conversations",
|
||||
"TOOLTIP": "Total number of conversations with SLA"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"HEADER": {
|
||||
"POLICY": "Policy",
|
||||
"CONVERSATION": "Conversation",
|
||||
"AGENT": "Agent"
|
||||
},
|
||||
"VIEW_DETAILS": "View Details"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,10 @@
|
||||
"CONVERSATION_CREATION": "Send email notifications when a new conversation is created",
|
||||
"CONVERSATION_MENTION": "Send email notifications when you are mentioned in a conversation",
|
||||
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in an assigned conversation",
|
||||
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation"
|
||||
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation",
|
||||
"SLA_MISSED_FIRST_RESPONSE": "Send email notifications when a conversation misses first response SLA",
|
||||
"SLA_MISSED_NEXT_RESPONSE": "Send email notifications when a conversation misses next response SLA",
|
||||
"SLA_MISSED_RESOLUTION": "Send email notifications when a conversation misses resolution SLA"
|
||||
},
|
||||
"API": {
|
||||
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
|
||||
@@ -98,7 +101,10 @@
|
||||
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in an assigned conversation",
|
||||
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in a participating conversation",
|
||||
"HAS_ENABLED_PUSH": "You have enabled push for this browser.",
|
||||
"REQUEST_PUSH": "Enable push notifications"
|
||||
"REQUEST_PUSH": "Enable push notifications",
|
||||
"SLA_MISSED_FIRST_RESPONSE": "Send push notifications when a conversation misses first response SLA",
|
||||
"SLA_MISSED_NEXT_RESPONSE": "Send push notifications when a conversation misses next response SLA",
|
||||
"SLA_MISSED_RESOLUTION": "Send push notifications when a conversation misses resolution SLA"
|
||||
},
|
||||
"PROFILE_IMAGE": {
|
||||
"LABEL": "Profile Image"
|
||||
@@ -234,6 +240,8 @@
|
||||
"CAMPAIGNS": "Campaigns",
|
||||
"ONGOING": "Ongoing",
|
||||
"ONE_OFF": "One off",
|
||||
"REPORTS_SLA": "SLA",
|
||||
"REPORTS_BOT": "Bot",
|
||||
"REPORTS_AGENT": "Agents",
|
||||
"REPORTS_LABEL": "Labels",
|
||||
"REPORTS_INBOX": "Inbox",
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
{
|
||||
"SLA": {
|
||||
"HEADER": "SLA",
|
||||
"HEADER_BTN_TXT": "Add SLA",
|
||||
"ADD_ACTION": "Add SLA",
|
||||
"ADD_ACTION_LONG": "Create a new SLA Policy",
|
||||
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
|
||||
"LEARN_MORE": "Learn more about SLA",
|
||||
"LOADING": "Fetching SLAs",
|
||||
"SEARCH_404": "There are no items matching this query",
|
||||
"SIDEBAR_TXT": "<p><b>SLA</b> <p>Think of Service Level Agreements (SLAs) like friendly promises between a service provider and a customer.</p> <p> These promises set clear expectations for things like how quickly the team will respond to issues, making sure you always get a reliable and top-notch experience!</p>",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to create SLAs",
|
||||
"AVAILABLE_ON": "SLA management is available on the <span class=\"font-medium text-slate-900\">Business and Enterprise</span> plans only.",
|
||||
"UPGRADE_PROMPT": "Upgrade now to get access to advanced features like team management, automations, custom attributes, and more.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"ASK_ADMIN": "Ask your administrator to upgrade and get access to advanced features like team management, automations, custom attributes, and more.",
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
},
|
||||
"LIST": {
|
||||
"404": "There are no SLAs available in this account.",
|
||||
"TITLE": "Manage SLA",
|
||||
"DESC": "SLAs: Friendly promises for great service!",
|
||||
"TABLE_HEADER": ["Name", "Description", "FRT", "NRT", "RT", "Business Hours"]
|
||||
"EMPTY": {
|
||||
"TITLE_1": "Enterprise P0",
|
||||
"DESC_1": "Issues raised by enterprise customers, that require immediate attention.",
|
||||
"TITLE_2": "Enterprise P1",
|
||||
"DESC_2": "Issues raised by enterprise customers, that needs to be acknowledged quickly."
|
||||
},
|
||||
"BUSINESS_HOURS_ON": "Business hours on",
|
||||
"BUSINESS_HOURS_OFF": "Business hours off",
|
||||
"RESPONSE_TYPES": {
|
||||
"FRT": "First response time threshold",
|
||||
"NRT": "Next response time threshold",
|
||||
"RT": "Resolution time threshold",
|
||||
"SHORT_HAND": {
|
||||
"FRT": "FRT",
|
||||
"NRT": "NRT",
|
||||
"RT": "RT"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FORM": {
|
||||
"NAME": {
|
||||
@@ -49,18 +73,32 @@
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Add SLA",
|
||||
"DESC": "SLAs: Friendly promises for great service!",
|
||||
"DESC": "Friendly promises for great service!",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "SLA added successfully",
|
||||
"ERROR_MESSAGE": "There was an error, please try again"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Edit SLA",
|
||||
"DELETE": {
|
||||
"TITLE": "Delete SLA",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "SLA updated successfully",
|
||||
"SUCCESS_MESSAGE": "SLA deleted successfully",
|
||||
"ERROR_MESSAGE": "There was an error, please try again"
|
||||
},
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirm Deletion",
|
||||
"MESSAGE": "Are you sure you want to delete ",
|
||||
"YES": "Yes, Delete ",
|
||||
"NO": "No, Keep "
|
||||
}
|
||||
},
|
||||
"EVENTS": {
|
||||
"TITLE": "SLA Misses",
|
||||
"FRT": "First response time",
|
||||
"NRT": "Next response time",
|
||||
"RT": "Resolution time",
|
||||
"SHOW_MORE": "{count} more",
|
||||
"HIDE": "Hide {count} rows"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,33 +29,6 @@ export default {
|
||||
conversationId() {
|
||||
return this.currentChat.id;
|
||||
},
|
||||
|
||||
filteredAttributes() {
|
||||
return Object.keys(this.customAttributes).map(key => {
|
||||
const item = this.attributes.find(
|
||||
attribute => attribute.attribute_key === key
|
||||
);
|
||||
if (item) {
|
||||
return {
|
||||
...item,
|
||||
value: this.customAttributes[key],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
value: this.customAttributes[key],
|
||||
attribute_description: key,
|
||||
attribute_display_name: key,
|
||||
attribute_display_type: this.attributeDisplayType(
|
||||
this.customAttributes[key]
|
||||
),
|
||||
attribute_key: key,
|
||||
attribute_model: this.attributeType,
|
||||
id: Math.random(),
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isAttributeNumber(attributeValue) {
|
||||
|
||||
@@ -4,10 +4,15 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({ accountLabels: 'labels/getLabels' }),
|
||||
savedLabels() {
|
||||
// If conversationLabels is passed as prop, use it
|
||||
if (this.conversationLabels)
|
||||
return this.conversationLabels.split(',').map(item => item.trim());
|
||||
// Otherwise, get labels from store
|
||||
return this.$store.getters['conversationLabels/getConversationLabels'](
|
||||
this.conversationId
|
||||
);
|
||||
},
|
||||
// TODO - Get rid of this from the mixin
|
||||
activeLabels() {
|
||||
return this.accountLabels.filter(({ title }) =>
|
||||
this.savedLabels.includes(title)
|
||||
|
||||
@@ -2,11 +2,19 @@ import { mapGetters } from 'vuex';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
accountSummaryKey: {
|
||||
type: String,
|
||||
default: 'getAccountSummary',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountSummary: 'getAccountSummary',
|
||||
accountReport: 'getAccountReports',
|
||||
}),
|
||||
accountSummary() {
|
||||
return this.$store.getters[this.accountSummaryKey];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
calculateTrend(key) {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
attribute_description: 'Product name',
|
||||
attribute_display_name: 'Product name',
|
||||
attribute_display_type: 'text',
|
||||
attribute_key: 'product_name',
|
||||
attribute_model: 'conversation_attribute',
|
||||
created_at: '2021-09-03T10:45:09.587Z',
|
||||
default_value: null,
|
||||
id: 6,
|
||||
updated_at: '2021-09-22T10:40:42.511Z',
|
||||
},
|
||||
{
|
||||
attribute_description: 'Product identifier',
|
||||
attribute_display_name: 'Product id',
|
||||
attribute_display_type: 'number',
|
||||
attribute_key: 'product_id',
|
||||
attribute_model: 'conversation_attribute',
|
||||
created_at: '2021-09-16T13:06:47.329Z',
|
||||
default_value: null,
|
||||
icon: 'fluent-calculator',
|
||||
id: 10,
|
||||
updated_at: '2021-09-22T10:42:25.873Z',
|
||||
value: 2021,
|
||||
},
|
||||
];
|
||||
@@ -1,7 +1,6 @@
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import attributeMixin from '../attributeMixin';
|
||||
import Vuex from 'vuex';
|
||||
import attributeFixtures from './attributeFixtures';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
@@ -41,43 +40,6 @@ describe('attributeMixin', () => {
|
||||
expect(wrapper.vm.conversationId).toEqual(7165);
|
||||
});
|
||||
|
||||
it('returns filtered attributes from conversation custom attributes', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [attributeMixin],
|
||||
computed: {
|
||||
attributes() {
|
||||
return attributeFixtures;
|
||||
},
|
||||
contact() {
|
||||
return {
|
||||
id: 7165,
|
||||
custom_attributes: {
|
||||
product_id: 2021,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.filteredAttributes).toEqual([
|
||||
{
|
||||
attribute_description: 'Product identifier',
|
||||
attribute_display_name: 'Product id',
|
||||
attribute_display_type: 'number',
|
||||
attribute_key: 'product_id',
|
||||
attribute_model: 'conversation_attribute',
|
||||
created_at: '2021-09-16T13:06:47.329Z',
|
||||
default_value: null,
|
||||
icon: 'fluent-calculator',
|
||||
id: 10,
|
||||
updated_at: '2021-09-22T10:42:25.873Z',
|
||||
value: 2021,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('return display type if attribute passed', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
|
||||
@@ -11,11 +11,42 @@ describe('reportMixin', () => {
|
||||
beforeEach(() => {
|
||||
getters = {
|
||||
getAccountSummary: () => reportFixtures.summary,
|
||||
getBotSummary: () => reportFixtures.botSummary,
|
||||
getAccountReports: () => reportFixtures.report,
|
||||
};
|
||||
store = new Vuex.Store({ getters });
|
||||
});
|
||||
|
||||
it('display the metric for account', async () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [reportMixin],
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
await wrapper.setProps({
|
||||
accountSummaryKey: 'getAccountSummary',
|
||||
});
|
||||
expect(wrapper.vm.displayMetric('conversations_count')).toEqual('5,000');
|
||||
expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual(
|
||||
'3 Min 18 Sec'
|
||||
);
|
||||
});
|
||||
|
||||
it('display the metric for bot', async () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [reportMixin],
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
await wrapper.setProps({
|
||||
accountSummaryKey: 'getBotSummary',
|
||||
});
|
||||
expect(wrapper.vm.displayMetric('bot_resolutions_count')).toEqual('10');
|
||||
expect(wrapper.vm.displayMetric('bot_handoffs_count')).toEqual('20');
|
||||
});
|
||||
|
||||
it('display the metric', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
|
||||
@@ -15,6 +15,14 @@ export default {
|
||||
},
|
||||
resolutions_count: 3,
|
||||
},
|
||||
botSummary: {
|
||||
bot_resolutions_count: 10,
|
||||
bot_handoffs_count: 20,
|
||||
previous: {
|
||||
bot_resolutions_count: 8,
|
||||
bot_handoffs_count: 5,
|
||||
},
|
||||
},
|
||||
report: {
|
||||
data: [
|
||||
{ value: '0.00', timestamp: 1647541800, count: 0 },
|
||||
|
||||
@@ -38,13 +38,10 @@
|
||||
:contact-id="contact.id"
|
||||
attribute-type="contact_attribute"
|
||||
attribute-class="conversation--attribute"
|
||||
attribute-from="contact_panel"
|
||||
:custom-attributes="contact.custom_attributes"
|
||||
class="even"
|
||||
/>
|
||||
<custom-attribute-selector
|
||||
attribute-type="contact_attribute"
|
||||
:contact-id="contact.id"
|
||||
/>
|
||||
</accordion-item>
|
||||
</div>
|
||||
<div v-if="element.name === 'contact_labels'">
|
||||
@@ -85,7 +82,6 @@ import ContactConversations from 'dashboard/routes/dashboard/conversation/Contac
|
||||
import ContactInfo from 'dashboard/routes/dashboard/conversation/contact/ContactInfo.vue';
|
||||
import ContactLabel from 'dashboard/routes/dashboard/contacts/components/ContactLabels.vue';
|
||||
import CustomAttributes from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue';
|
||||
import CustomAttributeSelector from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributeSelector.vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
|
||||
@@ -96,7 +92,6 @@ export default {
|
||||
ContactInfo,
|
||||
ContactLabel,
|
||||
CustomAttributes,
|
||||
CustomAttributeSelector,
|
||||
draggable,
|
||||
},
|
||||
mixins: [uiSettingsMixin],
|
||||
|
||||
+1
-1
@@ -243,7 +243,7 @@ export default {
|
||||
attr.attribute_display_type === 'checkbox'
|
||||
);
|
||||
});
|
||||
if (isCustomAttributeCheckbox) {
|
||||
if (isCustomAttributeCheckbox || type === 'blocked') {
|
||||
return [
|
||||
{
|
||||
id: true,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
@on-sort-change="onSortChange"
|
||||
/>
|
||||
<table-footer
|
||||
class="border-t border-slate-75 dark:border-slate-700/50"
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-count="meta.count"
|
||||
:page-size="15"
|
||||
|
||||
@@ -76,6 +76,14 @@ const filterTypes = [
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'blocked',
|
||||
attributeI18nKey: 'BLOCKED',
|
||||
inputType: 'search_select',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
];
|
||||
|
||||
export const filterAttributeGroups = [
|
||||
@@ -115,6 +123,10 @@ export const filterAttributeGroups = [
|
||||
key: 'last_activity_at',
|
||||
i18nKey: 'LAST_ACTIVITY',
|
||||
},
|
||||
{
|
||||
key: 'blocked',
|
||||
i18nKey: 'BLOCKED',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -87,10 +87,7 @@
|
||||
attribute-type="contact_attribute"
|
||||
attribute-class="conversation--attribute"
|
||||
class="even"
|
||||
:contact-id="contact.id"
|
||||
/>
|
||||
<custom-attribute-selector
|
||||
attribute-type="contact_attribute"
|
||||
attribute-from="conversation_contact_panel"
|
||||
:contact-id="contact.id"
|
||||
/>
|
||||
</accordion-item>
|
||||
@@ -142,7 +139,6 @@ import ConversationParticipant from './ConversationParticipant.vue';
|
||||
import ContactInfo from './contact/ContactInfo.vue';
|
||||
import ConversationInfo from './ConversationInfo.vue';
|
||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
||||
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import MacrosList from './Macros/List.vue';
|
||||
@@ -154,7 +150,6 @@ export default {
|
||||
ContactInfo,
|
||||
ConversationInfo,
|
||||
CustomAttributes,
|
||||
CustomAttributeSelector,
|
||||
ConversationAction,
|
||||
ConversationParticipant,
|
||||
draggable,
|
||||
|
||||
@@ -1,152 +1,109 @@
|
||||
<template>
|
||||
<div class="conversation--details">
|
||||
<contact-details-item
|
||||
v-if="initiatedAt"
|
||||
:title="$t('CONTACT_PANEL.INITIATED_AT')"
|
||||
:value="initiatedAt.timestamp"
|
||||
class="conversation--attribute"
|
||||
/>
|
||||
<contact-details-item
|
||||
v-if="browserLanguage"
|
||||
:title="$t('CONTACT_PANEL.BROWSER_LANGUAGE')"
|
||||
:value="browserLanguage"
|
||||
class="conversation--attribute"
|
||||
/>
|
||||
<contact-details-item
|
||||
v-if="referer"
|
||||
:title="$t('CONTACT_PANEL.INITIATED_FROM')"
|
||||
:value="referer"
|
||||
class="conversation--attribute"
|
||||
>
|
||||
<a :href="referer" rel="noopener noreferrer nofollow" target="_blank">
|
||||
{{ referer }}
|
||||
</a>
|
||||
</contact-details-item>
|
||||
<contact-details-item
|
||||
v-if="browserName"
|
||||
:title="$t('CONTACT_PANEL.BROWSER')"
|
||||
:value="browserName"
|
||||
class="conversation--attribute"
|
||||
/>
|
||||
<contact-details-item
|
||||
v-if="platformName"
|
||||
:title="$t('CONTACT_PANEL.OS')"
|
||||
:value="platformName"
|
||||
class="conversation--attribute"
|
||||
/>
|
||||
<contact-details-item
|
||||
v-if="ipAddress"
|
||||
:title="$t('CONTACT_PANEL.IP_ADDRESS')"
|
||||
:value="ipAddress"
|
||||
class="conversation--attribute"
|
||||
/>
|
||||
<custom-attributes
|
||||
attribute-type="conversation_attribute"
|
||||
attribute-class="conversation--attribute"
|
||||
:class="customAttributeRowClass"
|
||||
/>
|
||||
<custom-attribute-selector attribute-type="conversation_attribute" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getLanguageName } from '../../../components/widgets/conversation/advancedFilterItems/languages';
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { getLanguageName } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
||||
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue';
|
||||
const props = defineProps({
|
||||
conversationAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
contactAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContactDetailsItem,
|
||||
CustomAttributes,
|
||||
CustomAttributeSelector,
|
||||
},
|
||||
props: {
|
||||
conversationAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
contactAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
STATIC_ATTRIBUTES: [
|
||||
const referer = computed(() => props.conversationAttributes.referer);
|
||||
const initiatedAt = computed(
|
||||
() => props.conversationAttributes.initiated_at?.timestamp
|
||||
);
|
||||
|
||||
const browserInfo = props.conversationAttributes.browser;
|
||||
|
||||
const browserName = computed(() => {
|
||||
if (!browserInfo) return '';
|
||||
const { browser_name: name = '', browser_version: version = '' } =
|
||||
browserInfo;
|
||||
return `${name} ${version}`;
|
||||
});
|
||||
|
||||
const browserLanguage = computed(() =>
|
||||
getLanguageName(props.conversationAttributes.browser_language)
|
||||
);
|
||||
|
||||
const platformName = computed(() => {
|
||||
if (!browserInfo) return '';
|
||||
const { platform_name: name = '', platform_version: version = '' } =
|
||||
browserInfo;
|
||||
return `${name} ${version}`;
|
||||
});
|
||||
|
||||
const createdAtIp = computed(() => props.contactAttributes.created_at_ip);
|
||||
|
||||
const staticElements = computed(() =>
|
||||
[
|
||||
{
|
||||
name: 'initiated_at',
|
||||
label: 'CONTACT_PANEL.INITIATED_AT',
|
||||
content: initiatedAt,
|
||||
title: 'CONTACT_PANEL.INITIATED_AT',
|
||||
},
|
||||
{
|
||||
name: 'referer',
|
||||
label: 'CONTACT_PANEL.BROWSER',
|
||||
content: browserLanguage,
|
||||
title: 'CONTACT_PANEL.BROWSER_LANGUAGE',
|
||||
},
|
||||
{
|
||||
name: 'browserName',
|
||||
label: 'CONTACT_PANEL.BROWSER',
|
||||
content: referer,
|
||||
title: 'CONTACT_PANEL.INITIATED_FROM',
|
||||
type: 'link',
|
||||
},
|
||||
{
|
||||
name: 'platformName',
|
||||
label: 'CONTACT_PANEL.OS',
|
||||
content: browserName,
|
||||
title: 'CONTACT_PANEL.BROWSER',
|
||||
},
|
||||
{
|
||||
name: 'ipAddress',
|
||||
label: 'CONTACT_PANEL.IP_ADDRESS',
|
||||
content: platformName,
|
||||
title: 'CONTACT_PANEL.OS',
|
||||
},
|
||||
],
|
||||
computed: {
|
||||
referer() {
|
||||
return this.conversationAttributes.referer;
|
||||
{
|
||||
content: createdAtIp,
|
||||
title: 'CONTACT_PANEL.IP_ADDRESS',
|
||||
},
|
||||
initiatedAt() {
|
||||
return this.conversationAttributes.initiated_at;
|
||||
},
|
||||
browserName() {
|
||||
if (!this.conversationAttributes.browser) {
|
||||
return '';
|
||||
}
|
||||
const {
|
||||
browser_name: browserName = '',
|
||||
browser_version: browserVersion = '',
|
||||
} = this.conversationAttributes.browser;
|
||||
return `${browserName} ${browserVersion}`;
|
||||
},
|
||||
browserLanguage() {
|
||||
return getLanguageName(this.conversationAttributes.browser_language);
|
||||
},
|
||||
platformName() {
|
||||
if (!this.conversationAttributes.browser) {
|
||||
return '';
|
||||
}
|
||||
const { platform_name: platformName, platform_version: platformVersion } =
|
||||
this.conversationAttributes.browser;
|
||||
return `${platformName || ''} ${platformVersion || ''}`;
|
||||
},
|
||||
ipAddress() {
|
||||
const { created_at_ip: createdAtIp } = this.contactAttributes;
|
||||
return createdAtIp;
|
||||
},
|
||||
customAttributeRowClass() {
|
||||
const attributes = [
|
||||
'initiatedAt',
|
||||
'referer',
|
||||
'browserName',
|
||||
'platformName',
|
||||
'ipAddress',
|
||||
];
|
||||
const availableAttributes = attributes.filter(
|
||||
attribute => !!this[attribute]
|
||||
);
|
||||
return availableAttributes.length % 2 === 0 ? 'even' : 'odd';
|
||||
},
|
||||
},
|
||||
};
|
||||
].filter(attribute => !!attribute.content.value)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="conversation--details">
|
||||
<ContactDetailsItem
|
||||
v-for="element in staticElements"
|
||||
:key="element.title"
|
||||
:title="$t(element.title)"
|
||||
:value="element.content.value"
|
||||
class="conversation--attribute"
|
||||
>
|
||||
<a
|
||||
v-if="element.type === 'link'"
|
||||
:href="referer"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
class="text-woot-400 dark:text-woot-600"
|
||||
>
|
||||
{{ referer }}
|
||||
</a>
|
||||
</ContactDetailsItem>
|
||||
<CustomAttributes
|
||||
:class="staticElements.length % 2 === 0 ? 'even' : 'odd'"
|
||||
attribute-class="conversation--attribute"
|
||||
attribute-from="conversation_panel"
|
||||
attribute-type="conversation_attribute"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.conversation--attribute {
|
||||
@apply border-slate-50 dark:border-slate-700 border-b border-solid;
|
||||
|
||||
@apply border-slate-50 dark:border-slate-700/50 border-b border-solid;
|
||||
&:nth-child(2n) {
|
||||
@apply bg-slate-25 dark:bg-slate-800;
|
||||
@apply bg-slate-25 dark:bg-slate-800/50;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -125,8 +125,9 @@
|
||||
>
|
||||
<span
|
||||
class="flex items-center h-10 px-2 text-sm border-solid bg-slate-50 border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md dark:bg-slate-700 text-slate-800 dark:text-slate-100 border-slate-200 dark:border-slate-600"
|
||||
>{{ socialProfile.prefixURL }}</span
|
||||
>
|
||||
{{ socialProfile.prefixURL }}
|
||||
</span>
|
||||
<input
|
||||
v-model="socialProfileUserNames[socialProfile.key]"
|
||||
class="input-group-field ltr:rounded-l-none rtl:rounded-r-none !mb-0"
|
||||
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-full max-h-[12.5rem]">
|
||||
<h4
|
||||
class="text-sm text-slate-800 dark:text-slate-100 mb-1 overflow-hidden whitespace-nowrap text-ellipsis flex-grow"
|
||||
>
|
||||
{{ $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.TITLE') }}
|
||||
</h4>
|
||||
<div class="mb-2 flex-shrink-0 flex-grow-0 flex-auto max-h-8">
|
||||
<input
|
||||
ref="searchbar"
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="search-input"
|
||||
autofocus="true"
|
||||
:placeholder="$t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-start items-start flex-grow flex-shrink flex-auto overflow-auto h-32"
|
||||
>
|
||||
<div class="w-full h-full">
|
||||
<woot-dropdown-menu>
|
||||
<custom-attribute-drop-down-item
|
||||
v-for="attribute in filteredAttributes"
|
||||
:key="attribute.attribute_display_name"
|
||||
:title="attribute.attribute_display_name"
|
||||
@click="onAddAttribute(attribute)"
|
||||
/>
|
||||
</woot-dropdown-menu>
|
||||
<div
|
||||
v-if="noResult"
|
||||
class="w-full justify-center items-center flex mb-2 h-[70%] text-slate-500 dark:text-slate-300 py-2 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis text-sm"
|
||||
>
|
||||
{{ $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.NO_RESULT') }}
|
||||
</div>
|
||||
<woot-button
|
||||
class="float-right"
|
||||
icon="add"
|
||||
size="tiny"
|
||||
@click="addNewAttribute"
|
||||
>
|
||||
{{ $t('CUSTOM_ATTRIBUTES.FORM.ADD.TITLE') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CustomAttributeDropDownItem from './CustomAttributeDropDownItem.vue';
|
||||
import attributeMixin from 'dashboard/mixins/attributeMixin';
|
||||
export default {
|
||||
components: {
|
||||
CustomAttributeDropDownItem,
|
||||
},
|
||||
mixins: [attributeMixin],
|
||||
props: {
|
||||
attributeType: {
|
||||
type: String,
|
||||
default: 'conversation_attribute',
|
||||
},
|
||||
contactId: { type: Number, default: null },
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
search: '',
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredAttributes() {
|
||||
return this.attributes
|
||||
.filter(
|
||||
item =>
|
||||
!Object.keys(this.customAttributes).includes(item.attribute_key)
|
||||
)
|
||||
.filter(attribute => {
|
||||
return attribute.attribute_display_name
|
||||
.toLowerCase()
|
||||
.includes(this.search.toLowerCase());
|
||||
});
|
||||
},
|
||||
|
||||
noResult() {
|
||||
return this.filteredAttributes.length === 0;
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.focusInput();
|
||||
},
|
||||
|
||||
methods: {
|
||||
focusInput() {
|
||||
this.$refs.searchbar.focus();
|
||||
},
|
||||
addNewAttribute() {
|
||||
this.$router.push(
|
||||
`/app/accounts/${this.accountId}/settings/custom-attributes/list`
|
||||
);
|
||||
},
|
||||
async onAddAttribute(attribute) {
|
||||
this.$emit('add-attribute', attribute);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-input {
|
||||
@apply m-0 w-full border border-solid border-transparent h-8 text-sm text-slate-700 dark:text-slate-100 rounded-md focus:border-woot-500 bg-slate-50 dark:bg-slate-900;
|
||||
}
|
||||
</style>
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<woot-dropdown-item>
|
||||
<woot-button variant="clear" @click="onClick">
|
||||
<span class="label-text" :title="title">{{ title }}</span>
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AttributeDropDownItem',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClick() {
|
||||
this.$emit('click', this.title);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.item-wrap {
|
||||
display: flex;
|
||||
|
||||
::v-deep .button__content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-wrap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
&.active {
|
||||
display: flex;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--w-700);
|
||||
}
|
||||
|
||||
.name-label-wrap {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
.label-color--display {
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
|
||||
.label-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.1;
|
||||
padding-right: var(--space-small);
|
||||
padding-left: var(--space-small);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label-color--display {
|
||||
border-radius: var(--border-radius-normal);
|
||||
height: var(--space-slab);
|
||||
margin-right: var(--space-smaller);
|
||||
margin-top: var(--space-micro);
|
||||
min-width: var(--space-slab);
|
||||
width: var(--space-slab);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-138
@@ -1,138 +0,0 @@
|
||||
<template>
|
||||
<div class="custom-attribute--selector">
|
||||
<div
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="label-wrap"
|
||||
@keyup.esc="closeDropdown"
|
||||
>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="link"
|
||||
icon="add"
|
||||
@click="toggleAttributeDropDown"
|
||||
>
|
||||
{{ $t('CUSTOM_ATTRIBUTES.ADD_BUTTON_TEXT') }}
|
||||
</woot-button>
|
||||
|
||||
<div class="dropdown-wrap">
|
||||
<div
|
||||
:class="{ 'dropdown-pane--open': showAttributeDropDown }"
|
||||
class="dropdown-pane"
|
||||
>
|
||||
<custom-attribute-drop-down
|
||||
v-if="showAttributeDropDown"
|
||||
:attribute-type="attributeType"
|
||||
:contact-id="contactId"
|
||||
@add-attribute="addAttribute"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CustomAttributeDropDown from './CustomAttributeDropDown.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import attributeMixin from 'dashboard/mixins/attributeMixin';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CustomAttributeDropDown,
|
||||
},
|
||||
mixins: [clickaway, alertMixin, attributeMixin],
|
||||
props: {
|
||||
attributeType: {
|
||||
type: String,
|
||||
default: 'conversation_attribute',
|
||||
},
|
||||
contactId: { type: Number, default: null },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAttributeDropDown: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async addAttribute(attribute) {
|
||||
try {
|
||||
const {
|
||||
attribute_key: attributeKey,
|
||||
attribute_display_type: attributeDisplayType,
|
||||
default_value: attributeDefaultValue,
|
||||
} = attribute;
|
||||
const isCheckbox = attributeDisplayType === 'checkbox';
|
||||
const defaultValue = isCheckbox ? false : attributeDefaultValue || null;
|
||||
if (this.attributeType === 'conversation_attribute') {
|
||||
await this.$store.dispatch('updateCustomAttributes', {
|
||||
conversationId: this.conversationId,
|
||||
customAttributes: {
|
||||
...this.customAttributes,
|
||||
[attributeKey]: defaultValue,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.$store.dispatch('contacts/update', {
|
||||
id: this.contactId,
|
||||
custom_attributes: {
|
||||
...this.customAttributes,
|
||||
[attributeKey]: defaultValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
bus.$emit(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, attributeKey);
|
||||
this.showAlert(this.$t('CUSTOM_ATTRIBUTES.FORM.ADD.SUCCESS'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message ||
|
||||
this.$t('CUSTOM_ATTRIBUTES.FORM.ADD.ERROR');
|
||||
this.showAlert(errorMessage);
|
||||
} finally {
|
||||
this.closeDropdown();
|
||||
}
|
||||
},
|
||||
|
||||
toggleAttributeDropDown() {
|
||||
this.showAttributeDropDown = !this.showAttributeDropDown;
|
||||
},
|
||||
|
||||
closeDropdown() {
|
||||
this.showAttributeDropDown = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-attribute--selector {
|
||||
width: 100%;
|
||||
padding: var(--space-slab) var(--space-normal);
|
||||
|
||||
.label-wrap {
|
||||
line-height: var(--space-medium);
|
||||
position: relative;
|
||||
|
||||
.dropdown-wrap {
|
||||
display: flex;
|
||||
left: -1px;
|
||||
margin-right: var(--space-medium);
|
||||
position: absolute;
|
||||
top: var(--space-medium);
|
||||
width: 100%;
|
||||
|
||||
.dropdown-pane {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--r-500);
|
||||
font-size: var(--font-size-mini);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
</style>
|
||||
+80
-7
@@ -1,23 +1,35 @@
|
||||
<template>
|
||||
<div class="custom-attributes--panel">
|
||||
<custom-attribute
|
||||
v-for="attribute in filteredAttributes"
|
||||
v-for="attribute in displayedAttributes"
|
||||
:key="attribute.id"
|
||||
:attribute-key="attribute.attribute_key"
|
||||
:attribute-type="attribute.attribute_display_type"
|
||||
:values="attribute.attribute_values"
|
||||
:label="attribute.attribute_display_name"
|
||||
:icon="attribute.icon"
|
||||
emoji=""
|
||||
:value="attribute.value"
|
||||
:show-actions="true"
|
||||
:attribute-regex="attribute.regex_pattern"
|
||||
:regex-cue="attribute.regex_cue"
|
||||
:class="attributeClass"
|
||||
:contact-id="contactId"
|
||||
@update="onUpdate"
|
||||
@delete="onDelete"
|
||||
@copy="onCopy"
|
||||
/>
|
||||
<!-- Show more and show less buttons show it if the filteredAttributes length is greater than 5 -->
|
||||
<div v-if="filteredAttributes.length > 5" class="flex px-2 py-2">
|
||||
<woot-button
|
||||
size="small"
|
||||
:icon="showAllAttributes ? 'chevron-up' : 'chevron-down'"
|
||||
variant="clear"
|
||||
color-scheme="primary"
|
||||
class="!px-2 hover:!bg-transparent dark:hover:!bg-transparent"
|
||||
@click="onClickToggle"
|
||||
>
|
||||
{{ toggleButtonText }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,13 +37,14 @@
|
||||
import CustomAttribute from 'dashboard/components/CustomAttribute.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import attributeMixin from 'dashboard/mixins/attributeMixin';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CustomAttribute,
|
||||
},
|
||||
mixins: [alertMixin, attributeMixin],
|
||||
mixins: [alertMixin, attributeMixin, uiSettingsMixin],
|
||||
props: {
|
||||
attributeType: {
|
||||
type: String,
|
||||
@@ -42,8 +55,67 @@ export default {
|
||||
default: '',
|
||||
},
|
||||
contactId: { type: Number, default: null },
|
||||
attributeFrom: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAllAttributes: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
toggleButtonText() {
|
||||
return !this.showAllAttributes
|
||||
? this.$t('CUSTOM_ATTRIBUTES.SHOW_MORE')
|
||||
: this.$t('CUSTOM_ATTRIBUTES.SHOW_LESS');
|
||||
},
|
||||
filteredAttributes() {
|
||||
return this.attributes.map(attribute => {
|
||||
// Check if the attribute key exists in customAttributes
|
||||
const hasValue = Object.hasOwnProperty.call(
|
||||
this.customAttributes,
|
||||
attribute.attribute_key
|
||||
);
|
||||
|
||||
const isCheckbox = attribute.attribute_display_type === 'checkbox';
|
||||
const defaultValue = isCheckbox ? false : '';
|
||||
|
||||
return {
|
||||
...attribute,
|
||||
// Set value from customAttributes if it exists, otherwise use default value
|
||||
value: hasValue
|
||||
? this.customAttributes[attribute.attribute_key]
|
||||
: defaultValue,
|
||||
};
|
||||
});
|
||||
},
|
||||
displayedAttributes() {
|
||||
// Show only the first 5 attributes or all depending on showAllAttributes
|
||||
if (this.showAllAttributes || this.filteredAttributes.length <= 5) {
|
||||
return this.filteredAttributes;
|
||||
}
|
||||
return this.filteredAttributes.slice(0, 5);
|
||||
},
|
||||
showMoreUISettingsKey() {
|
||||
return `show_all_attributes_${this.attributeFrom}`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializeSettings();
|
||||
},
|
||||
methods: {
|
||||
initializeSettings() {
|
||||
this.showAllAttributes =
|
||||
this.uiSettings[this.showMoreUISettingsKey] || false;
|
||||
},
|
||||
onClickToggle() {
|
||||
this.showAllAttributes = !this.showAllAttributes;
|
||||
this.updateUISettings({
|
||||
[this.showMoreUISettingsKey]: this.showAllAttributes,
|
||||
});
|
||||
},
|
||||
async onUpdate(key, value) {
|
||||
const updatedAttributes = { ...this.customAttributes, [key]: value };
|
||||
try {
|
||||
@@ -96,16 +168,17 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.custom-attributes--panel {
|
||||
.conversation--attribute {
|
||||
@apply border-slate-50 dark:border-slate-700 border-b border-solid;
|
||||
@apply border-slate-50 dark:border-slate-700/50 border-b border-solid;
|
||||
}
|
||||
|
||||
&.odd {
|
||||
.conversation--attribute {
|
||||
&:nth-child(2n + 1) {
|
||||
@apply bg-slate-25 dark:bg-slate-800;
|
||||
@apply bg-slate-25 dark:bg-slate-800/50;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +186,7 @@ export default {
|
||||
&.even {
|
||||
.conversation--attribute {
|
||||
&:nth-child(2n) {
|
||||
@apply bg-slate-25 dark:bg-slate-800;
|
||||
@apply bg-slate-25 dark:bg-slate-800/50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
:current-page="currentPage"
|
||||
:total-count="totalCount"
|
||||
:page-size="pageSize"
|
||||
class="dark:bg-slate-900 sticky bottom-0"
|
||||
class="dark:bg-slate-900 bottom-0 border-t border-slate-75 dark:border-slate-700/50"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
@@ -9,6 +9,7 @@
|
||||
:on-mark-all-done-click="onMarkAllDoneClick"
|
||||
/>
|
||||
<table-footer
|
||||
class="border-t border-slate-75 dark:border-slate-700/50"
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-count="meta.count"
|
||||
:page-size="15"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col w-full h-full gap-10 font-inter">
|
||||
<slot name="header" />
|
||||
<div>
|
||||
<slot v-if="isLoading" name="loading">
|
||||
<woot-loading-state :message="loadingMessage" />
|
||||
</slot>
|
||||
<slot v-else name="body" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
keepAlive: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col w-full h-full px-5 pt-8 pb-3 m-0 overflow-auto bg-white sm:px-16 sm:pt-16 dark:bg-slate-900"
|
||||
>
|
||||
<div class="flex items-start max-w-[900px] w-full">
|
||||
<keep-alive v-if="keepAlive">
|
||||
<router-view />
|
||||
</keep-alive>
|
||||
<router-view v-else />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -57,7 +57,7 @@
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-count="meta.totalEntries"
|
||||
:page-size="meta.perPage"
|
||||
class="dark:bg-slate-900"
|
||||
class="!bg-slate-25 dark:!bg-slate-900 border-t border-slate-75 dark:border-slate-700/50"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
linkText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const openInNewTab = url => {
|
||||
if (!url) return;
|
||||
window.open(url, '_blank', 'noopener noreferrer');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start w-full gap-4">
|
||||
<!-- Header section with icon, title and action button -->
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<!-- Icon and title container -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex items-center w-10 h-10 p-1 rounded-full bg-woot-25/60 dark:bg-woot-900/60"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-full h-full rounded-full bg-woot-75/70 dark:bg-woot-800/40"
|
||||
>
|
||||
<fluent-icon
|
||||
size="14"
|
||||
:icon="iconName"
|
||||
type="outline"
|
||||
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1
|
||||
class="text-2xl font-medium tracking-[-1.5%] text-slate-900 dark:text-slate-25"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
<!-- Slot for additional actions on larger screens -->
|
||||
<div class="hidden gap-2 sm:flex">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Description and optional link -->
|
||||
<div
|
||||
class="flex flex-col gap-2 text-slate-600 dark:text-slate-300 max-w-[721px] w-full"
|
||||
>
|
||||
<p
|
||||
class="mb-0 text-sm font-normal tracking-[0.5%] line-clamp-5 sm:line-clamp-none"
|
||||
>
|
||||
<slot name="description">{{ description }}</slot>
|
||||
</p>
|
||||
<!-- Conditional link -->
|
||||
<a
|
||||
v-if="href && linkText"
|
||||
:href="href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="sm:inline-flex hidden tracking-[-0.6%] gap-1 w-fit items-center text-woot-500 dark:text-woot-500 text-sm font-medium tracking=[-0.6%] hover:underline"
|
||||
>
|
||||
{{ linkText }}
|
||||
<fluent-icon
|
||||
size="16"
|
||||
icon="chevron-right"
|
||||
type="outline"
|
||||
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Mobile view for actions and link -->
|
||||
<div class="flex items-start justify-start w-full gap-3 sm:hidden">
|
||||
<slot name="actions" />
|
||||
<woot-button
|
||||
v-if="href && linkText"
|
||||
color-scheme="secondary"
|
||||
icon="arrow-outwards"
|
||||
class="flex-row-reverse rounded-xl min-w-0 !bg-slate-50 !text-slate-900 dark:!text-white dark:!bg-slate-800"
|
||||
@click="openInNewTab(href)"
|
||||
>
|
||||
{{ linkText }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between shadow-sm group bg-white border border-solid rounded-xl dark:bg-slate-800 border-slate-75 dark:border-slate-700/50 max-w-[900px] w-full"
|
||||
>
|
||||
<!-- left side section -->
|
||||
<slot name="leftSection">
|
||||
<div class="flex flex-col min-w-0 items-start gap-3 max-w-[480px] w-full">
|
||||
<div
|
||||
class="flex items-center justify-between w-full gap-3 sm:justify-normal whitespace-nowrap"
|
||||
>
|
||||
<h3
|
||||
class="justify-between text-sm tracking-[-0.6%] font-medium truncate w-fit sm:justify-normal text-slate-900 dark:text-slate-25"
|
||||
>
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</h3>
|
||||
<slot name="label" />
|
||||
</div>
|
||||
<p
|
||||
class="text-sm text-slate-600 tracking-[0.5%] dark:text-slate-300 max-w-[400px] w-full line-clamp-2"
|
||||
>
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- right side section -->
|
||||
<slot name="rightSection" />
|
||||
|
||||
<!-- actions section -->
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="absolute flex-col items-center hidden gap-1 border-none ltr:-right-3 rtl:-left-3 top-3 group-hover:flex"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -589,7 +589,9 @@ export default {
|
||||
return this.inbox.name;
|
||||
},
|
||||
canLocktoSingleConversation() {
|
||||
return this.isASmsInbox || this.isAWhatsAppChannel;
|
||||
return (
|
||||
this.isASmsInbox || this.isAWhatsAppChannel || this.isAFacebookInbox
|
||||
);
|
||||
},
|
||||
inboxNameLabel() {
|
||||
if (this.isAWebWidgetInbox) {
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
import { required, minLength } from 'vuelidate/lib/validators';
|
||||
import { mapGetters } from 'vuex';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
export default {
|
||||
mixins: [alertMixin],
|
||||
@@ -125,10 +126,9 @@ export default {
|
||||
});
|
||||
this.errorMessage = this.$t('PROFILE_SETTINGS.PASSWORD_UPDATE_SUCCESS');
|
||||
} catch (error) {
|
||||
this.errorMessage = this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
|
||||
if (error?.response?.data?.message) {
|
||||
this.errorMessage = error.response.data.message;
|
||||
}
|
||||
this.errorMessage =
|
||||
parseAPIErrorResponse(error) ||
|
||||
this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
|
||||
} finally {
|
||||
this.isPasswordChanging = false;
|
||||
this.showAlert(this.errorMessage);
|
||||
|
||||
@@ -236,6 +236,54 @@
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="selectedEmailFlags"
|
||||
class="notification--checkbox"
|
||||
type="checkbox"
|
||||
value="email_sla_missed_first_response"
|
||||
@input="handleEmailInput"
|
||||
/>
|
||||
<label for="sla_missed_first_response">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_FIRST_RESPONSE'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="selectedEmailFlags"
|
||||
class="notification--checkbox"
|
||||
type="checkbox"
|
||||
value="email_sla_missed_next_response"
|
||||
@input="handleEmailInput"
|
||||
/>
|
||||
<label for="sla_missed_next_response">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_NEXT_RESPONSE'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="selectedEmailFlags"
|
||||
class="notification--checkbox"
|
||||
type="checkbox"
|
||||
value="email_sla_missed_resolution"
|
||||
@input="handleEmailInput"
|
||||
/>
|
||||
<label for="sla_missed_resolution">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_RESOLUTION'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -352,6 +400,57 @@
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="selectedPushFlags"
|
||||
class="notification--checkbox"
|
||||
type="checkbox"
|
||||
value="push_sla_missed_first_response"
|
||||
@input="handlePushInput"
|
||||
/>
|
||||
<label for="sla_missed_first_response">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_FIRST_RESPONSE'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="selectedPushFlags"
|
||||
class="notification--checkbox"
|
||||
type="checkbox"
|
||||
value="push_sla_missed_next_response"
|
||||
@input="handlePushInput"
|
||||
/>
|
||||
<label for="sla_missed_next_response">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_NEXT_RESPONSE'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="selectedPushFlags"
|
||||
class="notification--checkbox"
|
||||
type="checkbox"
|
||||
value="push_sla_missed_resolution"
|
||||
@input="handlePushInput"
|
||||
/>
|
||||
<label for="sla_missed_resolution">
|
||||
{{
|
||||
$t(
|
||||
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_RESOLUTION'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,6 +466,7 @@ import {
|
||||
requestPushPermissions,
|
||||
verifyServiceWorkerExistence,
|
||||
} from '../../../../helper/pushHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
export default {
|
||||
mixins: [alertMixin, configMixin, uiSettingsMixin],
|
||||
@@ -393,13 +493,18 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
emailFlags: 'userNotificationSettings/getSelectedEmailFlags',
|
||||
pushFlags: 'userNotificationSettings/getSelectedPushFlags',
|
||||
uiSettings: 'getUISettings',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
hasPushAPISupport() {
|
||||
return !!('Notification' in window);
|
||||
},
|
||||
isSLAEnabled() {
|
||||
return this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.SLA);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
emailFlags(value) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<report-filter-selector
|
||||
:show-agents-filter="false"
|
||||
:show-group-by-filter="true"
|
||||
:show-business-hours-switch="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
|
||||
<bot-metrics :filters="requestPayload" />
|
||||
<report-container
|
||||
:group-by="groupBy"
|
||||
:report-keys="reportKeys"
|
||||
:account-summary-key="'getBotSummary'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import BotMetrics from './components/BotMetrics.vue';
|
||||
import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import reportMixin from 'dashboard/mixins/reportMixin';
|
||||
import ReportContainer from './ReportContainer.vue';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
name: 'BotReports',
|
||||
components: {
|
||||
BotMetrics,
|
||||
ReportFilterSelector,
|
||||
ReportContainer,
|
||||
},
|
||||
mixins: [reportMixin],
|
||||
data() {
|
||||
return {
|
||||
from: 0,
|
||||
to: 0,
|
||||
groupBy: GROUP_BY_FILTER[1],
|
||||
reportKeys: {
|
||||
BOT_RESOLUTION_COUNT: 'bot_resolutions_count',
|
||||
BOT_HANDOFF_COUNT: 'bot_handoffs_count',
|
||||
},
|
||||
businessHours: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountReport: 'getAccountReports',
|
||||
}),
|
||||
requestPayload() {
|
||||
return {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchAllData() {
|
||||
this.fetchBotSummary();
|
||||
this.fetchChartData();
|
||||
},
|
||||
fetchBotSummary() {
|
||||
try {
|
||||
this.$store.dispatch('fetchBotSummary', this.getRequestPayload());
|
||||
} catch {
|
||||
this.showAlert(this.$t('REPORT.SUMMARY_FETCHING_FAILED'));
|
||||
}
|
||||
},
|
||||
fetchChartData() {
|
||||
Object.keys(this.reportKeys).forEach(async key => {
|
||||
try {
|
||||
await this.$store.dispatch('fetchAccountReport', {
|
||||
metric: this.reportKeys[key],
|
||||
...this.getRequestPayload(),
|
||||
});
|
||||
} catch {
|
||||
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
|
||||
}
|
||||
});
|
||||
},
|
||||
getRequestPayload() {
|
||||
const { from, to, groupBy, businessHours } = this;
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
groupBy: groupBy?.period,
|
||||
businessHours,
|
||||
};
|
||||
},
|
||||
onFilterChange({ from, to, groupBy, businessHours }) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.groupBy = groupBy;
|
||||
this.businessHours = businessHours;
|
||||
this.fetchAllData();
|
||||
|
||||
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterValue: { from, to, groupBy, businessHours },
|
||||
reportType: 'bots',
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
:key="metric.KEY"
|
||||
class="p-4 rounded-md mb-3"
|
||||
>
|
||||
<chart-stats :metric="metric" />
|
||||
<chart-stats :metric="metric" :account-summary-key="accountSummaryKey" />
|
||||
<div class="mt-4 h-72">
|
||||
<woot-loading-state
|
||||
v-if="accountReport.isFetching[metric.KEY]"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user