Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd1d88d50 | ||
|
|
e8152642f2 | ||
|
|
dae4f3ee13 | ||
|
|
e75e8a77f6 | ||
|
|
229f56d6e3 | ||
|
|
138840a23f | ||
|
|
c5f6844877 | ||
|
|
39243b9e71 | ||
|
|
fb2f5e1d42 | ||
|
|
cfe3061b5d | ||
|
|
aa7e3c2d38 | ||
|
|
3874383698 | ||
|
|
101eca3003 | ||
|
|
61eaa098ae | ||
|
|
9cd7c4ef89 | ||
|
|
f4538ae2c5 | ||
|
|
fd5ac2a8a3 | ||
|
|
6b7180d051 | ||
|
|
4d362da9f0 | ||
|
|
2c2f0547f7 | ||
|
|
c7193c7917 | ||
|
|
d272a64ff7 | ||
|
|
b2cb3717e5 | ||
|
|
00ed074d72 | ||
|
|
7b512bd00e | ||
|
|
8f95fafff4 | ||
|
|
0ad47d87f4 | ||
|
|
e65ea24360 | ||
|
|
b252656984 | ||
|
|
6e397c7571 | ||
|
|
4622560fac | ||
|
|
6632610e78 | ||
|
|
bd732f1fa9 | ||
|
|
67112647e8 | ||
|
|
04c456e0a3 | ||
|
|
656ae41b24 | ||
|
|
f83415f299 | ||
|
|
0a910c3763 | ||
|
|
6a7cbcf5ba | ||
|
|
0e30e3c00a | ||
|
|
0d3b59fd9c | ||
|
|
04e747cc02 | ||
|
|
053b7774dd | ||
|
|
8eaea7c72e | ||
|
|
1345f67966 | ||
|
|
0f914fa2ab | ||
|
|
59663dd558 |
@@ -144,7 +144,7 @@ jobs:
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
<<: *defaults
|
||||
parallelism: 20
|
||||
parallelism: 18
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
@@ -350,12 +350,12 @@ jobs:
|
||||
destination: coverage
|
||||
|
||||
build:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- run:
|
||||
name: Legacy build aggregator
|
||||
command: |
|
||||
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
|
||||
<<: *defaults
|
||||
steps:
|
||||
- run:
|
||||
name: Legacy build aggregator
|
||||
command: |
|
||||
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
|
||||
@@ -94,6 +94,7 @@ yarn-debug.log*
|
||||
.vscode
|
||||
.claude/settings.local.json
|
||||
.cursor
|
||||
.codex/
|
||||
CLAUDE.local.md
|
||||
|
||||
# Histoire deployment
|
||||
@@ -101,3 +102,4 @@ CLAUDE.local.md
|
||||
.histoire
|
||||
.pnpm-store/*
|
||||
local/
|
||||
Procfile.worktree
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
- **Setup**: `bundle install && pnpm install`
|
||||
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
|
||||
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
|
||||
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
|
||||
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
|
||||
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
|
||||
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
|
||||
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
|
||||
- **Lint Ruby**: `bundle exec rubocop -a`
|
||||
- **Test JS**: `pnpm test` or `pnpm test:watch`
|
||||
@@ -50,6 +55,13 @@
|
||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
|
||||
|
||||
## Codex Worktree Workflow
|
||||
|
||||
- Use a separate git worktree + branch per task to keep changes isolated.
|
||||
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
|
||||
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
|
||||
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
|
||||
|
||||
## Commit Messages
|
||||
|
||||
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
||||
@@ -86,3 +98,7 @@ Practical checklist for any change impacting core logic or public APIs
|
||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
|
||||
|
||||
## Branding / White-labeling note
|
||||
|
||||
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
|
||||
|
||||
@@ -191,12 +191,14 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents', '>= 0.7.0'
|
||||
gem 'ai-agents'
|
||||
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm', '>= 1.8.2'
|
||||
gem 'ruby_llm-schema'
|
||||
|
||||
gem 'cld3', '~> 3.7'
|
||||
|
||||
# OpenTelemetry for LLM observability
|
||||
gem 'opentelemetry-sdk'
|
||||
gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
+19
-17
@@ -126,8 +126,8 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.7.0)
|
||||
ruby_llm (~> 1.8.2)
|
||||
ai-agents (0.9.0)
|
||||
ruby_llm (~> 1.9.1)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
@@ -186,6 +186,7 @@ GEM
|
||||
byebug (11.1.3)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
cld3 (3.7.0)
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
@@ -297,7 +298,7 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.1)
|
||||
faraday (2.14.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -308,12 +309,12 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday-retry (2.4.0)
|
||||
faraday (~> 2.0)
|
||||
faraday_middleware-aws-sigv4 (1.0.1)
|
||||
aws-sigv4 (~> 1.0)
|
||||
@@ -464,7 +465,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.13.2)
|
||||
json (2.18.1)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -539,11 +540,11 @@ GEM
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
marcel (1.1.0)
|
||||
maxminddb (0.1.22)
|
||||
meta_request (0.8.3)
|
||||
meta_request (0.8.5)
|
||||
rack-contrib (>= 1.1, < 3)
|
||||
railties (>= 3.0.0, < 8)
|
||||
railties (>= 3.0.0, < 9)
|
||||
method_source (1.1.0)
|
||||
mime-types (3.4.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
@@ -558,12 +559,12 @@ GEM
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.8.0)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.3.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.20)
|
||||
@@ -824,7 +825,7 @@ GEM
|
||||
ruby2ruby (2.5.0)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_llm (1.8.2)
|
||||
ruby_llm (1.9.2)
|
||||
base64
|
||||
event_stream_parser (~> 1)
|
||||
faraday (>= 1.10.0)
|
||||
@@ -968,7 +969,7 @@ GEM
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.0.4)
|
||||
uri (1.1.1)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
@@ -1003,7 +1004,7 @@ GEM
|
||||
working_hours (1.4.1)
|
||||
activesupport (>= 3.2)
|
||||
tzinfo
|
||||
zeitwerk (2.6.17)
|
||||
zeitwerk (2.7.4)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
@@ -1023,7 +1024,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.7.0)
|
||||
ai-agents
|
||||
annotaterb
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
@@ -1037,6 +1038,7 @@ DEPENDENCIES
|
||||
bullet
|
||||
bundle-audit
|
||||
byebug
|
||||
cld3 (~> 3.7)
|
||||
climate_control
|
||||
commonmarker
|
||||
csv-safe
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.10.1
|
||||
4.11.0
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
class V2::Reports::OutgoingMessagesCountBuilder
|
||||
include DateRangeHelper
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
send("build_by_#{params[:group_by]}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def base_messages
|
||||
account.messages.outgoing.unscope(:order).where(created_at: range)
|
||||
end
|
||||
|
||||
def build_by_agent
|
||||
counts = base_messages
|
||||
.where(sender_type: 'User')
|
||||
.where.not(sender_id: nil)
|
||||
.group(:sender_id)
|
||||
.count
|
||||
|
||||
user_names = account.users.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |user_id, count|
|
||||
user = user_names[user_id]
|
||||
{ id: user_id, name: user&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_team
|
||||
counts = base_messages
|
||||
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
|
||||
.where.not(conversations: { team_id: nil })
|
||||
.group('conversations.team_id')
|
||||
.count
|
||||
|
||||
team_names = account.teams.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |team_id, count|
|
||||
team = team_names[team_id]
|
||||
{ id: team_id, name: team&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_inbox
|
||||
counts = base_messages
|
||||
.group(:inbox_id)
|
||||
.count
|
||||
|
||||
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
|
||||
|
||||
counts.map do |inbox_id, count|
|
||||
inbox = inbox_names[inbox_id]
|
||||
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
|
||||
def build_by_label
|
||||
counts = base_messages
|
||||
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
|
||||
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
|
||||
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
|
||||
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
|
||||
.group('tags.name')
|
||||
.count
|
||||
|
||||
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
|
||||
|
||||
counts.map do |label_name, count|
|
||||
label = label_ids[label_name]
|
||||
{ id: label&.id, name: label_name, outgoing_messages_count: count }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -70,6 +70,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def transcript
|
||||
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
|
||||
return render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled?
|
||||
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
|
||||
|
||||
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
|
||||
|
||||
@@ -35,7 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def transcript
|
||||
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
|
||||
return head :too_many_requests if conversation.blank?
|
||||
return head :payment_required unless conversation.account.email_transcript_enabled?
|
||||
return head :too_many_requests unless conversation.account.within_email_rate_limit?
|
||||
|
||||
send_transcript_email
|
||||
head :ok
|
||||
|
||||
@@ -78,6 +78,15 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze
|
||||
|
||||
def outgoing_messages_count
|
||||
return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by])
|
||||
|
||||
builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_csv(filename, template)
|
||||
@@ -171,4 +180,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
|
||||
def outgoing_messages_count_params
|
||||
{
|
||||
group_by: params[:group_by],
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
|
||||
def create
|
||||
@user = User.from_email(params[:email])
|
||||
if @user
|
||||
@user.send_reset_password_instructions
|
||||
build_response(I18n.t('messages.reset_password_success'), 200)
|
||||
else
|
||||
build_response(I18n.t('messages.reset_password_failure'), 404)
|
||||
end
|
||||
@user&.send_reset_password_instructions
|
||||
build_response(I18n.t('messages.reset_password'), 200)
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
class Webhooks::ShopifyController < ActionController::API
|
||||
before_action :verify_hmac!
|
||||
|
||||
def events
|
||||
case request.headers['X-Shopify-Topic']
|
||||
when 'shop/redact'
|
||||
handle_shop_redact
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verify_hmac!
|
||||
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
|
||||
return head :unauthorized if secret.blank?
|
||||
|
||||
data = request.body.read
|
||||
request.body.rewind
|
||||
|
||||
hmac_header = request.headers['X-Shopify-Hmac-SHA256']
|
||||
return head :unauthorized if hmac_header.blank?
|
||||
|
||||
computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data))
|
||||
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header)
|
||||
end
|
||||
|
||||
def handle_shop_redact
|
||||
shop_domain = params[:shop_domain]
|
||||
return if shop_domain.blank?
|
||||
|
||||
Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all
|
||||
end
|
||||
end
|
||||
@@ -37,17 +37,14 @@ class TasksAPI extends ApiClient {
|
||||
/**
|
||||
* Summarizes a conversation.
|
||||
* @param {string} conversationId - The conversation ID to summarize.
|
||||
* @param {Object} [options] - Additional options.
|
||||
* @param {boolean} [options.forceRegenerate] - Force regeneration of cached summary.
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the summary.
|
||||
*/
|
||||
summarize(conversationId, { forceRegenerate = false, signal } = {}) {
|
||||
summarize(conversationId, signal) {
|
||||
return axios.post(
|
||||
`${this.url}/summarize`,
|
||||
{
|
||||
conversation_display_id: conversationId,
|
||||
force_regenerate: forceRegenerate,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
-3
@@ -39,7 +39,6 @@ const policyA = withCount({
|
||||
description: 'Distributes conversations evenly among available agents',
|
||||
assignmentOrder: 'round_robin',
|
||||
conversationPriority: 'high',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[0], mockInboxes[1]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
@@ -50,7 +49,6 @@ const policyB = withCount({
|
||||
description: 'Assigns based on capacity and workload',
|
||||
assignmentOrder: 'capacity_based',
|
||||
conversationPriority: 'medium',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[2], mockInboxes[3]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
@@ -61,7 +59,6 @@ const emptyPolicy = withCount({
|
||||
description: 'Policy with no assigned inboxes',
|
||||
assignmentOrder: 'manual',
|
||||
conversationPriority: 'low',
|
||||
enabled: false,
|
||||
inboxes: [],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
-17
@@ -15,7 +15,6 @@ const props = defineProps({
|
||||
assignmentOrder: { type: String, default: '' },
|
||||
conversationPriority: { type: String, default: '' },
|
||||
assignedInboxCount: { type: Number, default: 0 },
|
||||
enabled: { type: Boolean, default: false },
|
||||
inboxes: { type: Array, default: () => [] },
|
||||
isFetchingInboxes: { type: Boolean, default: false },
|
||||
});
|
||||
@@ -65,22 +64,6 @@ const handleFetchInboxes = () => {
|
||||
{{ name }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
|
||||
>
|
||||
{{
|
||||
enabled
|
||||
? t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
|
||||
)
|
||||
: t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<CardPopover
|
||||
:title="
|
||||
t(
|
||||
|
||||
@@ -19,11 +19,15 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
const emit = defineEmits(['delete', 'navigate']);
|
||||
|
||||
const handleDelete = itemId => {
|
||||
emit('delete', itemId);
|
||||
};
|
||||
|
||||
const handleNavigate = item => {
|
||||
emit('navigate', item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,7 +51,11 @@ const handleDelete = itemId => {
|
||||
:key="item.id"
|
||||
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 col-span-2 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 rounded-lg py-1 px-1.5 -ml-1.5 transition-colors cursor-pointer group"
|
||||
@click="handleNavigate(item)"
|
||||
>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
@@ -61,10 +69,16 @@ const handleDelete = itemId => {
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 truncate min-w-0">
|
||||
<span
|
||||
class="text-sm text-n-slate-12 truncate min-w-0 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
<Icon
|
||||
icon="i-lucide-external-link"
|
||||
class="size-3.5 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-2 col-span-1">
|
||||
<span
|
||||
|
||||
+18
-4
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
@@ -15,6 +15,9 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', {
|
||||
},
|
||||
});
|
||||
|
||||
// The model value is in seconds (for the backend/DB)
|
||||
// DurationInput works in minutes internally
|
||||
// We need to convert between seconds and minutes
|
||||
const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||
type: Number,
|
||||
default: 3600,
|
||||
@@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||
|
||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
||||
|
||||
// Convert seconds to minutes for DurationInput
|
||||
const windowInMinutes = computed({
|
||||
get() {
|
||||
return Math.floor((fairDistributionWindow.value || 0) / 60);
|
||||
},
|
||||
set(minutes) {
|
||||
fairDistributionWindow.value = minutes * 60;
|
||||
},
|
||||
});
|
||||
|
||||
// Detect unit based on minutes (converted from seconds)
|
||||
const detectUnit = minutes => {
|
||||
const m = Number(minutes) || 0;
|
||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||
@@ -34,7 +48,7 @@ const detectUnit = minutes => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
windowUnit.value = detectUnit(fairDistributionWindow.value);
|
||||
windowUnit.value = detectUnit(windowInMinutes.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -73,9 +87,9 @@ onMounted(() => {
|
||||
<div
|
||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||
>
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<!-- allow 10 mins to 999 days (in minutes) -->
|
||||
<DurationInput
|
||||
v-model:model-value="fairDistributionWindow"
|
||||
v-model:model-value="windowInMinutes"
|
||||
v-model:unit="windowUnit"
|
||||
:min="10"
|
||||
:max="1438560"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
@@ -16,12 +18,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabledMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleChange = () => {
|
||||
if (!props.isActive) {
|
||||
if (!props.isActive && !props.disabled) {
|
||||
emit('select', props.id);
|
||||
}
|
||||
};
|
||||
@@ -29,9 +41,11 @@ const handleChange = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
||||
class="relative rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
||||
:class="[
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
|
||||
!disabled && !isActive ? 'hover:outline-n-strong' : '',
|
||||
]"
|
||||
@click="handleChange"
|
||||
>
|
||||
@@ -41,6 +55,7 @@ const handleChange = () => {
|
||||
:checked="isActive"
|
||||
:value="id"
|
||||
:name="id"
|
||||
:disabled="disabled"
|
||||
type="radio"
|
||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
||||
@change="handleChange"
|
||||
@@ -49,11 +64,23 @@ const handleChange = () => {
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col gap-3 items-start">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="disabled"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ description }}
|
||||
{{ disabled && disabledMessage ? disabledMessage : description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
-4
@@ -6,7 +6,6 @@ const policyName = ref('Round Robin Policy');
|
||||
const description = ref(
|
||||
'Distributes conversations evenly among available agents'
|
||||
);
|
||||
const enabled = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,13 +18,10 @@ const enabled = ref(true);
|
||||
<BaseInfo
|
||||
v-model:policy-name="policyName"
|
||||
v-model:description="description"
|
||||
v-model:enabled="enabled"
|
||||
name-label="Policy Name"
|
||||
name-placeholder="Enter policy name"
|
||||
description-label="Description"
|
||||
description-placeholder="Enter policy description"
|
||||
status-label="Status"
|
||||
status-placeholder="Active"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
+1
-2
@@ -68,8 +68,7 @@ const hasActiveSegments = computed(
|
||||
);
|
||||
const activeSegmentName = computed(() => props.activeSegment?.name);
|
||||
|
||||
const openCreateNewContactDialog = async () => {
|
||||
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
|
||||
const openCreateNewContactDialog = () => {
|
||||
createNewContactDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
const openContactImportDialog = () =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
@@ -59,6 +59,23 @@ const isFetchingInboxes = ref(false);
|
||||
const isSearching = ref(false);
|
||||
const showComposeNewConversation = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
message: '',
|
||||
subject: '',
|
||||
ccEmails: '',
|
||||
bccEmails: '',
|
||||
attachedFiles: [],
|
||||
});
|
||||
|
||||
const clearFormState = () => {
|
||||
Object.assign(formState, {
|
||||
subject: '',
|
||||
ccEmails: '',
|
||||
bccEmails: '',
|
||||
attachedFiles: [],
|
||||
});
|
||||
};
|
||||
|
||||
const contactById = useMapGetter('contacts/getContactById');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
@@ -140,12 +157,14 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||
|
||||
const handleTargetInbox = inbox => {
|
||||
targetInbox.value = inbox;
|
||||
if (!inbox) clearFormState();
|
||||
resetContacts();
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
selectedContact.value = null;
|
||||
targetInbox.value = null;
|
||||
clearFormState();
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
@@ -160,6 +179,12 @@ const closeCompose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const discardCompose = () => {
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
closeCompose();
|
||||
};
|
||||
|
||||
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||
try {
|
||||
const data = await store.dispatch('contactConversations/create', {
|
||||
@@ -171,7 +196,7 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
|
||||
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
|
||||
};
|
||||
closeCompose();
|
||||
discardCompose();
|
||||
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
|
||||
return true; // Return success
|
||||
} catch (error) {
|
||||
@@ -193,7 +218,11 @@ watch(
|
||||
(currentContact, previousContact) => {
|
||||
if (currentContact && props.contactId) {
|
||||
// Reset on contact change
|
||||
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
|
||||
if (currentContact?.id !== previousContact?.id) {
|
||||
clearSelectedContact();
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
}
|
||||
|
||||
// First process the contactable inboxes to get the right structure
|
||||
const processedInboxes = processContactableInboxes(
|
||||
@@ -265,6 +294,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@click.self="onModalBackdropClick"
|
||||
>
|
||||
<ComposeNewConversationForm
|
||||
:form-state="formState"
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
@@ -285,7 +315,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@update-target-inbox="handleTargetInbox"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@create-conversation="createConversation"
|
||||
@discard="closeCompose"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-3
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { reactive, ref, computed } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, requiredIf } from '@vuelidate/validators';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
@@ -37,6 +37,7 @@ const props = defineProps({
|
||||
contactsUiFlags: { type: Object, default: null },
|
||||
messageSignature: { type: String, default: '' },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
formState: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -57,13 +58,13 @@ const showBccEmailsDropdown = ref(false);
|
||||
|
||||
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
|
||||
|
||||
const state = reactive({
|
||||
const state = props.formState || {
|
||||
message: '',
|
||||
subject: '',
|
||||
ccEmails: '',
|
||||
bccEmails: '',
|
||||
attachedFiles: [],
|
||||
});
|
||||
};
|
||||
|
||||
const inboxTypes = computed(() => ({
|
||||
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
|
||||
|
||||
@@ -95,6 +95,7 @@ const inputClass = computed(() => {
|
||||
:show-dropdown="showCcEmailsDropdown"
|
||||
:is-loading="isLoading"
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
@focus="emit('updateDropdown', 'cc', true)"
|
||||
@input="emit('searchCcEmails', $event)"
|
||||
@@ -127,6 +128,7 @@ const inputClass = computed(() => {
|
||||
:show-dropdown="showBccEmailsDropdown"
|
||||
:is-loading="isLoading"
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
focus-on-mount
|
||||
@focus="emit('updateDropdown', 'bcc', true)"
|
||||
|
||||
@@ -56,8 +56,13 @@ const selectedLabel = computed(() => {
|
||||
});
|
||||
|
||||
const selectOption = option => {
|
||||
selectedValue.value = option.value;
|
||||
emit('update:modelValue', option.value);
|
||||
if (selectedValue.value === option.value) {
|
||||
selectedValue.value = '';
|
||||
emit('update:modelValue', '');
|
||||
} else {
|
||||
selectedValue.value = option.value;
|
||||
emit('update:modelValue', option.value);
|
||||
}
|
||||
open.value = false;
|
||||
search.value = '';
|
||||
};
|
||||
|
||||
@@ -53,6 +53,11 @@ const props = defineProps({
|
||||
default: 'lg',
|
||||
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
|
||||
},
|
||||
position: {
|
||||
type: String,
|
||||
default: 'center',
|
||||
validator: value => ['center', 'top'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm', 'close']);
|
||||
@@ -61,6 +66,7 @@ const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const dialogContentRef = ref(null);
|
||||
const isOpen = ref(false);
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
const classesMap = {
|
||||
@@ -75,13 +81,19 @@ const maxWidthClass = computed(() => {
|
||||
return classesMap[props.width] ?? 'max-w-md';
|
||||
});
|
||||
|
||||
const positionClass = computed(() =>
|
||||
props.position === 'top' ? 'dialog-position-top' : ''
|
||||
);
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true;
|
||||
dialogRef.value?.showModal();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
dialogRef.value?.close();
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
@@ -98,6 +110,7 @@ defineExpose({ open, close });
|
||||
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
||||
:class="[
|
||||
maxWidthClass,
|
||||
positionClass,
|
||||
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
|
||||
]"
|
||||
@close="close"
|
||||
@@ -105,7 +118,7 @@ defineExpose({ open, close });
|
||||
<OnClickOutside @trigger="close">
|
||||
<form
|
||||
ref="dialogContentRef"
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
@submit.prevent="confirm"
|
||||
@click.stop
|
||||
>
|
||||
@@ -119,7 +132,7 @@ defineExpose({ open, close });
|
||||
</p>
|
||||
</slot>
|
||||
</div>
|
||||
<slot />
|
||||
<slot v-if="isOpen" />
|
||||
<!-- Dialog content will be injected here -->
|
||||
<slot name="footer">
|
||||
<div
|
||||
@@ -156,4 +169,9 @@ defineExpose({ open, close });
|
||||
dialog::backdrop {
|
||||
@apply bg-n-alpha-black1 backdrop-blur-[4px];
|
||||
}
|
||||
|
||||
.dialog-position-top {
|
||||
margin-top: clamp(2rem, 5vh, 5rem);
|
||||
margin-bottom: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,10 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: 'max-h-96',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -15,7 +19,10 @@ defineProps({
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
<ul class="gap-2 grid reset-base list-none px-2 max-h-96 overflow-y-auto">
|
||||
<ul
|
||||
class="gap-2 grid reset-base list-none px-2 overflow-y-auto"
|
||||
:class="height"
|
||||
>
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -50,12 +50,12 @@ const currentFilter = computed(() =>
|
||||
);
|
||||
|
||||
const getOperator = (filter, selectedOperator) => {
|
||||
const operatorFromOptions = filter.filterOperators.find(
|
||||
const operatorFromOptions = filter?.filterOperators?.find(
|
||||
operator => operator.value === selectedOperator
|
||||
);
|
||||
|
||||
if (!operatorFromOptions) {
|
||||
return filter.filterOperators[0];
|
||||
return filter?.filterOperators?.[0];
|
||||
}
|
||||
|
||||
return operatorFromOptions;
|
||||
@@ -138,7 +138,11 @@ const validate = () => {
|
||||
return !validationError.value;
|
||||
};
|
||||
|
||||
defineExpose({ validate });
|
||||
const resetValidation = () => {
|
||||
showErrors.value = false;
|
||||
};
|
||||
|
||||
defineExpose({ validate, resetValidation });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -166,18 +170,20 @@ defineExpose({ validate });
|
||||
<FilterSelect
|
||||
v-model="filterOperator"
|
||||
variant="ghost"
|
||||
:options="currentFilter.filterOperators"
|
||||
:options="currentFilter?.filterOperators"
|
||||
/>
|
||||
<template v-if="currentOperator.hasInput">
|
||||
<template v-if="currentOperator?.hasInput">
|
||||
<MultiSelect
|
||||
v-if="inputType === 'multiSelect'"
|
||||
v-model="values"
|
||||
:options="currentFilter.options"
|
||||
dropdown-max-height="max-h-72"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'searchSelect'"
|
||||
v-model="values"
|
||||
:options="currentFilter.options"
|
||||
dropdown-max-height="max-h-64"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'booleanSelect'"
|
||||
|
||||
@@ -45,7 +45,7 @@ const { height } = useWindowSize();
|
||||
const { height: dropdownHeight } = useElementBounding(dropdownRef);
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find(o => o.value === selected.value) || {};
|
||||
return props.options?.find(o => o.value === selected.value) || {};
|
||||
});
|
||||
|
||||
const iconToRender = computed(() => {
|
||||
@@ -87,18 +87,25 @@ const updateSelected = newValue => {
|
||||
</template>
|
||||
<DropdownBody
|
||||
ref="dropdownRef"
|
||||
class="min-w-48 z-50"
|
||||
class="min-w-56 z-50"
|
||||
:class="dropdownPosition"
|
||||
strong
|
||||
>
|
||||
<DropdownSection class="[&>ul]:max-h-80">
|
||||
<DropdownItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:icon="option.icon"
|
||||
@click="updateSelected(option.value)"
|
||||
/>
|
||||
<DropdownSection class="[&>ul]:max-h-72">
|
||||
<template v-for="option in options" :key="option.value">
|
||||
<li
|
||||
v-if="option.disabled"
|
||||
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
|
||||
>
|
||||
{{ option.label }}
|
||||
</li>
|
||||
<DropdownItem
|
||||
v-else
|
||||
:label="option.label"
|
||||
:icon="option.icon"
|
||||
@click="updateSelected(option.value)"
|
||||
/>
|
||||
</template>
|
||||
</DropdownSection>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
|
||||
@@ -8,7 +8,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
const { options, maxChips } = defineProps({
|
||||
const { options, maxChips, dropdownMaxHeight } = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
@@ -17,6 +17,10 @@ const { options, maxChips } = defineProps({
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
dropdownMaxHeight: {
|
||||
type: String,
|
||||
default: 'max-h-80',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -123,7 +127,7 @@ const toggleOption = option => {
|
||||
</Button>
|
||||
</template>
|
||||
<DropdownBody class="top-0 min-w-48 z-50" strong>
|
||||
<DropdownSection class="[&>ul]:max-h-80">
|
||||
<DropdownSection :height="dropdownMaxHeight">
|
||||
<DropdownItem
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
|
||||
@@ -12,10 +12,12 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
const {
|
||||
options,
|
||||
disableSearch,
|
||||
disableDeselect,
|
||||
placeholderIcon,
|
||||
placeholder,
|
||||
placeholderTrailingIcon,
|
||||
searchPlaceholder,
|
||||
dropdownMaxHeight,
|
||||
} = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
@@ -41,6 +43,14 @@ const {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
dropdownMaxHeight: {
|
||||
type: String,
|
||||
default: 'max-h-80',
|
||||
},
|
||||
disableDeselect: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -63,6 +73,8 @@ const selectedItem = computed(() => {
|
||||
const optionToSearch = Array.isArray(selected.value)
|
||||
? selected.value[0]
|
||||
: selected.value;
|
||||
|
||||
if (!optionToSearch) return null;
|
||||
// extract the selected item from the options array
|
||||
// this ensures that options like icon is also included
|
||||
return options.find(option => option.id === optionToSearch.id);
|
||||
@@ -77,7 +89,7 @@ const toggleSelected = option => {
|
||||
};
|
||||
|
||||
if (selected.value && selected.value.id === optionToToggle.id) {
|
||||
selected.value = null;
|
||||
if (!disableDeselect) selected.value = null;
|
||||
} else {
|
||||
selected.value = optionToToggle;
|
||||
}
|
||||
@@ -124,7 +136,7 @@ const toggleSelected = option => {
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection class="[&>ul]:max-h-80">
|
||||
<DropdownSection :height="dropdownMaxHeight">
|
||||
<template v-if="searchResults.length">
|
||||
<DropdownItem
|
||||
v-for="option in searchResults"
|
||||
|
||||
@@ -43,6 +43,7 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
||||
|
||||
import MessageError from './MessageError.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Attachment
|
||||
@@ -143,6 +144,7 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
@@ -389,13 +391,17 @@ const shouldRenderMessage = computed(() => {
|
||||
const isUnsupported = props.contentAttributes?.isUnsupported;
|
||||
const isAnIntegrationMessage =
|
||||
props.contentType === CONTENT_TYPES.INTEGRATIONS;
|
||||
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
|
||||
const hasExternalError = !!props.contentAttributes?.externalError;
|
||||
|
||||
return (
|
||||
hasAttachments ||
|
||||
props.content ||
|
||||
isEmailContentType ||
|
||||
isUnsupported ||
|
||||
isAnIntegrationMessage
|
||||
isAnIntegrationMessage ||
|
||||
isFailedMessage ||
|
||||
hasExternalError
|
||||
);
|
||||
});
|
||||
|
||||
@@ -472,7 +478,7 @@ const avatarInfo = computed(() => {
|
||||
|
||||
const avatarTooltip = computed(() => {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
return t('CONVERSATION.NATIVE_APP_ADVISORY');
|
||||
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
|
||||
}
|
||||
if (avatarInfo.value.name === '') return '';
|
||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||
|
||||
@@ -12,11 +12,16 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(['retry']);
|
||||
|
||||
const { orientation, status, createdAt } = useMessageContext();
|
||||
const { orientation, status, createdAt, content, attachments } =
|
||||
useMessageContext();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
|
||||
const canRetry = computed(() => {
|
||||
const hasContent = content.value !== null;
|
||||
const hasAttachments = attachments.value && attachments.value.length > 0;
|
||||
return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
@@ -50,6 +51,18 @@ const isRTL = useMapGetter('accounts/isRTL');
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const hasAdvancedAssignment = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
|
||||
);
|
||||
});
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -584,12 +597,16 @@ const menuItems = computed(() => {
|
||||
icon: 'i-lucide-users',
|
||||
to: accountScopedRoute('settings_teams_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Agent Assignment',
|
||||
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
||||
icon: 'i-lucide-user-cog',
|
||||
to: accountScopedRoute('assignment_policy_index'),
|
||||
},
|
||||
...(hasAdvancedAssignment.value
|
||||
? [
|
||||
{
|
||||
name: 'Settings Agent Assignment',
|
||||
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
||||
icon: 'i-lucide-user-cog',
|
||||
to: accountScopedRoute('assignment_policy_index'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Settings Inboxes',
|
||||
label: t('SIDEBAR.INBOXES'),
|
||||
|
||||
@@ -36,6 +36,11 @@ const props = defineProps({
|
||||
},
|
||||
focusOnMount: { type: Boolean, default: false },
|
||||
allowCreate: { type: Boolean, default: false },
|
||||
// Skip label-based dedup when the consumer already filters menuItems by ID.
|
||||
// Prevents removing all same-name items when one is selected (e.g. duplicate agent names).
|
||||
skipLabelDedup: { type: Boolean, default: false },
|
||||
// When false, the dropdown won't auto-open on mount; it opens only on click/focus.
|
||||
autoOpenDropdown: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -56,7 +61,7 @@ const modelValue = defineModel({
|
||||
const tagInputRef = ref(null);
|
||||
const tags = ref(props.modelValue);
|
||||
const newTag = ref('');
|
||||
const isFocused = ref(true);
|
||||
const isFocused = ref(props.autoOpenDropdown);
|
||||
|
||||
const rules = computed(() => getValidationRules(props.type));
|
||||
const v$ = useVuelidate(rules, { newTag });
|
||||
@@ -74,11 +79,11 @@ const showInput = computed(() =>
|
||||
const showDropdownMenu = computed(() =>
|
||||
props.mode === MODE.SINGLE && tags.value.length >= 1
|
||||
? false
|
||||
: props.showDropdown
|
||||
: props.showDropdown && isFocused.value
|
||||
);
|
||||
|
||||
const filteredMenuItems = computed(() =>
|
||||
buildTagMenuItems({
|
||||
const filteredMenuItems = computed(() => {
|
||||
const items = buildTagMenuItems({
|
||||
mode: props.mode,
|
||||
tags: tags.value,
|
||||
menuItems: props.menuItems,
|
||||
@@ -86,8 +91,14 @@ const filteredMenuItems = computed(() =>
|
||||
isLoading: props.isLoading,
|
||||
type: props.type,
|
||||
isNewTagInValidType: isNewTagInValidType.value,
|
||||
})
|
||||
);
|
||||
allowCreate: props.allowCreate,
|
||||
skipLabelDedup: props.skipLabelDedup,
|
||||
});
|
||||
if (props.type !== INPUT_TYPES.TEXT) return items;
|
||||
const query = newTag.value?.trim()?.toLowerCase();
|
||||
if (!query) return items;
|
||||
return items.filter(item => item.label?.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
const emitDataOnAdd = value => {
|
||||
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
|
||||
@@ -112,10 +123,13 @@ const addTag = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
|
||||
props.allowCreate
|
||||
) {
|
||||
const isValidatedType = [INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(
|
||||
props.type
|
||||
);
|
||||
|
||||
if (!isValidatedType && !props.allowCreate && props.showDropdown) return;
|
||||
|
||||
if (isValidatedType || props.allowCreate) {
|
||||
if (!(await v$.value.$validate())) return;
|
||||
emitDataOnAdd(trimmedTag);
|
||||
}
|
||||
@@ -125,28 +139,31 @@ const addTag = async () => {
|
||||
const removeTag = index => {
|
||||
tags.value.splice(index, 1);
|
||||
modelValue.value = tags.value;
|
||||
emit('remove');
|
||||
emit('remove', index);
|
||||
};
|
||||
|
||||
const handleDropdownAction = async ({
|
||||
email: emailAddress,
|
||||
phoneNumber,
|
||||
label,
|
||||
...rest
|
||||
}) => {
|
||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
|
||||
if (!props.showDropdown) return;
|
||||
|
||||
const isEmail = props.type === 'email';
|
||||
newTag.value = isEmail ? emailAddress : phoneNumber;
|
||||
const isEmail = props.type === INPUT_TYPES.EMAIL;
|
||||
const tagValue = isEmail ? emailAddress : phoneNumber || label;
|
||||
|
||||
if (!(await v$.value.$validate())) return;
|
||||
if (isEmail || props.type === INPUT_TYPES.TEL) {
|
||||
newTag.value = tagValue;
|
||||
if (!(await v$.value.$validate())) return;
|
||||
}
|
||||
|
||||
const payload = isEmail
|
||||
? { email: emailAddress, ...rest }
|
||||
: { phoneNumber, ...rest };
|
||||
|
||||
emit('add', payload);
|
||||
updateValueAndFocus(emailAddress);
|
||||
emit(
|
||||
'add',
|
||||
isEmail ? { email: emailAddress, ...rest } : { phoneNumber, label, ...rest }
|
||||
);
|
||||
updateValueAndFocus(tagValue);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -163,7 +180,7 @@ const handleKeydown = event => {
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (tags.value.length) isFocused.value = false;
|
||||
isFocused.value = false;
|
||||
emit('onClickOutside');
|
||||
};
|
||||
|
||||
|
||||
@@ -261,6 +261,71 @@ describe('tagInputHelper', () => {
|
||||
});
|
||||
expect(result).toEqual(menuItems);
|
||||
});
|
||||
|
||||
it('does not create suggestion when allowCreate is false', () => {
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
type: INPUT_TYPES.EMAIL,
|
||||
newTag: 'test@example.com',
|
||||
allowCreate: false,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('still returns available menu items when allowCreate is false', () => {
|
||||
const menuItems = [
|
||||
{ label: 'Agent 1', value: '1' },
|
||||
{ label: 'Agent 2', value: '2' },
|
||||
];
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
menuItems,
|
||||
newTag: 'Agent',
|
||||
allowCreate: false,
|
||||
});
|
||||
expect(result).toEqual(menuItems);
|
||||
});
|
||||
|
||||
it('creates new item when allowCreate is true (default)', () => {
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
type: INPUT_TYPES.EMAIL,
|
||||
newTag: 'test@example.com',
|
||||
allowCreate: true,
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
label: 'test@example.com',
|
||||
action: 'create',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips label dedup when skipLabelDedup is true', () => {
|
||||
const menuItems = [
|
||||
{ label: 'HDMA', value: 1 },
|
||||
{ label: 'HDMA', value: 2 },
|
||||
];
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
tags: ['HDMA'],
|
||||
menuItems,
|
||||
skipLabelDedup: true,
|
||||
});
|
||||
expect(result).toEqual(menuItems);
|
||||
});
|
||||
|
||||
it('filters by label when skipLabelDedup is false (default)', () => {
|
||||
const menuItems = [
|
||||
{ label: 'HDMA', value: 1 },
|
||||
{ label: 'HDMA', value: 2 },
|
||||
];
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
tags: ['HDMA'],
|
||||
menuItems,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAddTag', () => {
|
||||
|
||||
@@ -84,25 +84,29 @@ export const buildTagMenuItems = ({
|
||||
isLoading,
|
||||
type,
|
||||
isNewTagInValidType,
|
||||
allowCreate = true,
|
||||
skipLabelDedup = false,
|
||||
}) => {
|
||||
if (mode === MODE.SINGLE && tags.length >= 1) return [];
|
||||
|
||||
const availableMenuItems = menuItems.filter(
|
||||
item => !tags.includes(item.label)
|
||||
);
|
||||
const availableMenuItems = skipLabelDedup
|
||||
? menuItems
|
||||
: menuItems.filter(item => !tags.includes(item.label));
|
||||
|
||||
// Show typed value as suggestion only if:
|
||||
// 1. There's a value being typed
|
||||
// 2. The value isn't already in the tags
|
||||
// 3. Validation passes (email/phone) and There are no menu items available
|
||||
// 4. allowCreate is enabled
|
||||
const trimmedNewTag = newTag?.trim();
|
||||
const shouldShowTypedValue =
|
||||
const shouldShowCreateSuggestion =
|
||||
allowCreate &&
|
||||
trimmedNewTag &&
|
||||
!tags.includes(trimmedNewTag) &&
|
||||
!isLoading &&
|
||||
!availableMenuItems.length;
|
||||
|
||||
if (shouldShowTypedValue) {
|
||||
if (shouldShowCreateSuggestion) {
|
||||
const { isValid, formattedValue } = validateAndFormatNewTag(
|
||||
trimmedNewTag,
|
||||
type,
|
||||
|
||||
@@ -145,6 +145,7 @@ const {
|
||||
isConversationSelected,
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onRemoveLabels,
|
||||
onAssignTeamsForBulk,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
@@ -859,6 +860,7 @@ provide('deSelectConversation', deSelectConversation);
|
||||
provide('assignAgent', onAssignAgent);
|
||||
provide('assignTeam', onAssignTeam);
|
||||
provide('assignLabels', onAssignLabels);
|
||||
provide('removeLabels', onRemoveLabels);
|
||||
provide('updateConversationStatus', handleResolveConversation);
|
||||
provide('toggleContextMenu', onContextMenuToggle);
|
||||
provide('markAsUnread', markAsUnread);
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
'assignAgent',
|
||||
'assignTeam',
|
||||
'assignLabels',
|
||||
'removeLabels',
|
||||
'updateConversationStatus',
|
||||
'toggleContextMenu',
|
||||
'markAsUnread',
|
||||
@@ -63,6 +64,7 @@ export default {
|
||||
@assign-agent="assignAgent"
|
||||
@assign-team="assignTeam"
|
||||
@assign-label="assignLabels"
|
||||
@remove-label="removeLabels"
|
||||
@update-conversation-status="updateConversationStatus"
|
||||
@context-menu-toggle="toggleContextMenu"
|
||||
@mark-as-unread="markAsUnread"
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
getActiveDateRange,
|
||||
moveCalendarDate,
|
||||
DATE_RANGE_TYPES,
|
||||
CALENDAR_TYPES,
|
||||
CALENDAR_PERIODS,
|
||||
isNavigableRange,
|
||||
getRangeAtOffset,
|
||||
} from './helpers/DatePickerHelper';
|
||||
import {
|
||||
isValid,
|
||||
@@ -13,14 +16,14 @@ import {
|
||||
subDays,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
isBefore,
|
||||
subMonths,
|
||||
addMonths,
|
||||
isSameMonth,
|
||||
differenceInCalendarMonths,
|
||||
differenceInCalendarWeeks,
|
||||
setMonth,
|
||||
setYear,
|
||||
isAfter,
|
||||
getWeek,
|
||||
} from 'date-fns';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import DatePickerButton from './components/DatePickerButton.vue';
|
||||
@@ -32,98 +35,187 @@ import CalendarWeek from './components/CalendarWeek.vue';
|
||||
import CalendarFooter from './components/CalendarFooter.vue';
|
||||
|
||||
const emit = defineEmits(['dateRangeChanged']);
|
||||
const { LAST_7_DAYS, LAST_30_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
|
||||
const { t } = useI18n();
|
||||
|
||||
const dateRange = defineModel('dateRange', {
|
||||
type: Array,
|
||||
default: undefined,
|
||||
});
|
||||
|
||||
const rangeType = defineModel('rangeType', {
|
||||
type: String,
|
||||
default: undefined,
|
||||
});
|
||||
const { LAST_7_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
|
||||
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
|
||||
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
|
||||
|
||||
const showDatePicker = ref(false);
|
||||
const calendarViews = ref({ start: WEEK, end: WEEK });
|
||||
const currentDate = ref(new Date());
|
||||
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
|
||||
const selectedEndDate = ref(endOfDay(currentDate.value));
|
||||
// Setting the start and end calendar
|
||||
const startCurrentDate = ref(startOfDay(selectedStartDate.value));
|
||||
|
||||
// Use dates from v-model if provided, otherwise default to last 7 days
|
||||
const selectedStartDate = ref(
|
||||
dateRange.value?.[0]
|
||||
? startOfDay(dateRange.value[0])
|
||||
: startOfDay(subDays(currentDate.value, 6)) // LAST_7_DAYS
|
||||
);
|
||||
const selectedEndDate = ref(
|
||||
dateRange.value?.[1]
|
||||
? endOfDay(dateRange.value[1])
|
||||
: endOfDay(currentDate.value)
|
||||
);
|
||||
// Calendar month positioning (left and right calendars)
|
||||
// These control which months are displayed in the dual calendar view
|
||||
const startCurrentDate = ref(startOfMonth(selectedStartDate.value));
|
||||
const endCurrentDate = ref(
|
||||
isSameMonth(selectedStartDate.value, selectedEndDate.value)
|
||||
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Moves to the start of the next month if dates are in the same month (Mounted case LAST_7_DAYS)
|
||||
: startOfMonth(selectedEndDate.value) // Always shows the month of the end date starting from the first (Mounted case LAST_7_DAYS)
|
||||
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Same month: show next month on right (e.g., Jan 25-31 shows Jan + Feb)
|
||||
: startOfMonth(selectedEndDate.value) // Different months: show end month on right (e.g., Dec 5 - Jan 3 shows Dec + Jan)
|
||||
);
|
||||
const selectingEndDate = ref(false);
|
||||
const selectedRange = ref(LAST_7_DAYS);
|
||||
const selectedRange = ref(rangeType.value || LAST_7_DAYS);
|
||||
const hoveredEndDate = ref(null);
|
||||
const monthOffset = ref(0);
|
||||
|
||||
const showMonthNavigation = computed(() =>
|
||||
isNavigableRange(selectedRange.value)
|
||||
);
|
||||
const canNavigateNext = computed(() => {
|
||||
if (!isNavigableRange(selectedRange.value)) return false;
|
||||
// Compare selected start to the current period's start to determine if we're in the past
|
||||
const currentRange = getActiveDateRange(
|
||||
selectedRange.value,
|
||||
currentDate.value
|
||||
);
|
||||
return selectedStartDate.value < currentRange.start;
|
||||
});
|
||||
|
||||
const navigationLabel = computed(() => {
|
||||
const range = selectedRange.value;
|
||||
if (range === DATE_RANGE_TYPES.MONTH_TO_DATE) {
|
||||
return new Intl.DateTimeFormat(navigator.language, {
|
||||
month: 'long',
|
||||
}).format(selectedStartDate.value);
|
||||
}
|
||||
if (range === DATE_RANGE_TYPES.THIS_WEEK) {
|
||||
const currentWeekRange = getActiveDateRange(range, currentDate.value);
|
||||
const isCurrentWeek =
|
||||
selectedStartDate.value.getTime() === currentWeekRange.start.getTime();
|
||||
if (isCurrentWeek) return null;
|
||||
const weekNumber = getWeek(selectedStartDate.value, { weekStartsOn: 1 });
|
||||
return t('DATE_PICKER.WEEK_NUMBER', { weekNumber });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const manualStartDate = ref(selectedStartDate.value);
|
||||
const manualEndDate = ref(selectedEndDate.value);
|
||||
|
||||
// Watcher will set the start and end dates based on the selected range
|
||||
watch(selectedRange, newRange => {
|
||||
if (newRange !== CUSTOM_RANGE) {
|
||||
// If selecting a range other than last 7 days or last 30 days, set the start and end dates to the selected start and end dates
|
||||
// If selecting last 7 days or last 30 days is, set the start date to the selected start date
|
||||
// and the end date to one month ahead of the start date if the start date and end date are in the same month
|
||||
// Otherwise set the end date to the selected end date
|
||||
const isLastSevenOrThirtyDays =
|
||||
newRange === LAST_7_DAYS || newRange === LAST_30_DAYS;
|
||||
startCurrentDate.value = selectedStartDate.value;
|
||||
endCurrentDate.value =
|
||||
isLastSevenOrThirtyDays &&
|
||||
isSameMonth(selectedStartDate.value, selectedEndDate.value)
|
||||
? startOfMonth(addMonths(selectedStartDate.value, 1))
|
||||
: selectedEndDate.value;
|
||||
selectingEndDate.value = false;
|
||||
} else if (!selectingEndDate.value) {
|
||||
// If selecting a custom range and not selecting an end date, set the start date to the selected start date
|
||||
startCurrentDate.value = startOfDay(currentDate.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Watcher will set the input values based on the selected start and end dates
|
||||
// Watcher 1: Sync v-model props from parent component
|
||||
// Handles: URL params, parent component updates, rangeType changes
|
||||
watch(
|
||||
[selectedStartDate, selectedEndDate],
|
||||
([newStart, newEnd]) => {
|
||||
if (isValid(newStart)) {
|
||||
manualStartDate.value = newStart;
|
||||
} else {
|
||||
manualStartDate.value = selectedStartDate.value;
|
||||
[rangeType, dateRange],
|
||||
([newRangeType, newDateRange]) => {
|
||||
if (newRangeType && newRangeType !== selectedRange.value) {
|
||||
selectedRange.value = newRangeType;
|
||||
monthOffset.value = 0;
|
||||
|
||||
// If rangeType changes without dateRange, recompute dates from the range
|
||||
if (!newDateRange && newRangeType !== CUSTOM_RANGE) {
|
||||
const activeDates = getActiveDateRange(newRangeType, currentDate.value);
|
||||
if (activeDates) {
|
||||
selectedStartDate.value = startOfDay(activeDates.start);
|
||||
selectedEndDate.value = endOfDay(activeDates.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid(newEnd)) {
|
||||
manualEndDate.value = newEnd;
|
||||
} else {
|
||||
manualEndDate.value = selectedEndDate.value;
|
||||
// When parent provides new dateRange (e.g., from URL params)
|
||||
// Skip if navigating with arrows — offset controls dates in that case
|
||||
if (newDateRange?.[0] && newDateRange?.[1] && monthOffset.value === 0) {
|
||||
selectedStartDate.value = startOfDay(newDateRange[0]);
|
||||
selectedEndDate.value = endOfDay(newDateRange[1]);
|
||||
|
||||
// Update calendar to show the months of the new date range
|
||||
startCurrentDate.value = startOfMonth(newDateRange[0]);
|
||||
endCurrentDate.value = isSameMonth(newDateRange[0], newDateRange[1])
|
||||
? startOfMonth(addMonths(newDateRange[1], 1))
|
||||
: startOfMonth(newDateRange[1]);
|
||||
|
||||
// Recalculate offset so arrow navigation is relative to restored range
|
||||
// TODO: When offset resolves to 0 (current period), the end date may be
|
||||
// stale if the URL was saved on a previous day. "This month" / "This week"
|
||||
// should show up-to-today dates for the current period. For now, the stale
|
||||
// end date is shown until the user clicks an arrow or re-selects the range.
|
||||
if (isNavigableRange(selectedRange.value)) {
|
||||
const current = getActiveDateRange(
|
||||
selectedRange.value,
|
||||
currentDate.value
|
||||
);
|
||||
if (selectedRange.value === DATE_RANGE_TYPES.THIS_WEEK) {
|
||||
monthOffset.value = differenceInCalendarWeeks(
|
||||
newDateRange[0],
|
||||
current.start,
|
||||
{ weekStartsOn: 1 }
|
||||
);
|
||||
} else {
|
||||
monthOffset.value = differenceInCalendarMonths(
|
||||
newDateRange[0],
|
||||
current.start
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Watcher to ensure dates are always in logical order
|
||||
// This watch is will ensure that the start date is always before the end date
|
||||
// Watcher 2: Keep manual input fields in sync with selected dates
|
||||
// Updates the input field values when dates change programmatically
|
||||
watch(
|
||||
[startCurrentDate, endCurrentDate],
|
||||
([newStart, newEnd], [oldStart, oldEnd]) => {
|
||||
const monthDifference = differenceInCalendarMonths(newEnd, newStart);
|
||||
|
||||
if (newStart !== oldStart) {
|
||||
if (isAfter(newStart, newEnd) || monthDifference === 0) {
|
||||
// Adjust the end date forward if the start date is adjusted and is after the end date or in the same month
|
||||
endCurrentDate.value = addMonths(newStart, 1);
|
||||
}
|
||||
}
|
||||
if (newEnd !== oldEnd) {
|
||||
if (isBefore(newEnd, newStart) || monthDifference === 0) {
|
||||
// Adjust the start date backward if the end date is adjusted and is before the start date or in the same month
|
||||
startCurrentDate.value = subMonths(newEnd, 1);
|
||||
}
|
||||
}
|
||||
[selectedStartDate, selectedEndDate],
|
||||
([newStart, newEnd]) => {
|
||||
manualStartDate.value = isValid(newStart)
|
||||
? newStart
|
||||
: selectedStartDate.value;
|
||||
manualEndDate.value = isValid(newEnd) ? newEnd : selectedEndDate.value;
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const setDateRange = range => {
|
||||
selectedRange.value = range.value;
|
||||
monthOffset.value = 0;
|
||||
const { start, end } = getActiveDateRange(range.value, currentDate.value);
|
||||
selectedStartDate.value = start;
|
||||
selectedEndDate.value = end;
|
||||
|
||||
// Position calendar to show the months of the selected range
|
||||
startCurrentDate.value = startOfMonth(start);
|
||||
endCurrentDate.value = isSameMonth(start, end)
|
||||
? startOfMonth(addMonths(start, 1))
|
||||
: startOfMonth(end);
|
||||
};
|
||||
|
||||
const navigateMonth = direction => {
|
||||
monthOffset.value += direction === 'prev' ? -1 : 1;
|
||||
if (monthOffset.value > 0) monthOffset.value = 0;
|
||||
|
||||
const { start, end } = getRangeAtOffset(
|
||||
selectedRange.value,
|
||||
monthOffset.value,
|
||||
currentDate.value
|
||||
);
|
||||
selectedStartDate.value = start;
|
||||
selectedEndDate.value = end;
|
||||
|
||||
startCurrentDate.value = startOfMonth(start);
|
||||
endCurrentDate.value = isSameMonth(start, end)
|
||||
? startOfMonth(addMonths(start, 1))
|
||||
: startOfMonth(end);
|
||||
|
||||
emit('dateRangeChanged', [start, end, selectedRange.value]);
|
||||
};
|
||||
|
||||
const moveCalendar = (calendar, direction, period = MONTH) => {
|
||||
@@ -134,12 +226,27 @@ const moveCalendar = (calendar, direction, period = MONTH) => {
|
||||
direction,
|
||||
period
|
||||
);
|
||||
startCurrentDate.value = start;
|
||||
endCurrentDate.value = end;
|
||||
|
||||
// Prevent calendar months from overlapping
|
||||
const monthDiff = differenceInCalendarMonths(end, start);
|
||||
if (monthDiff === 0) {
|
||||
// If they would be the same month, adjust the other calendar
|
||||
if (calendar === START_CALENDAR) {
|
||||
endCurrentDate.value = addMonths(start, 1);
|
||||
startCurrentDate.value = start;
|
||||
} else {
|
||||
startCurrentDate.value = subMonths(end, 1);
|
||||
endCurrentDate.value = end;
|
||||
}
|
||||
} else {
|
||||
startCurrentDate.value = start;
|
||||
endCurrentDate.value = end;
|
||||
}
|
||||
};
|
||||
|
||||
const selectDate = day => {
|
||||
selectedRange.value = CUSTOM_RANGE;
|
||||
monthOffset.value = 0;
|
||||
if (!selectingEndDate.value || day < selectedStartDate.value) {
|
||||
selectedStartDate.value = day;
|
||||
selectedEndDate.value = null;
|
||||
@@ -175,10 +282,10 @@ const openCalendar = (index, calendarType, period = MONTH) => {
|
||||
const updateManualInput = (newDate, calendarType) => {
|
||||
if (calendarType === START_CALENDAR) {
|
||||
selectedStartDate.value = newDate;
|
||||
startCurrentDate.value = newDate;
|
||||
startCurrentDate.value = startOfMonth(newDate);
|
||||
} else {
|
||||
selectedEndDate.value = newDate;
|
||||
endCurrentDate.value = newDate;
|
||||
endCurrentDate.value = startOfMonth(newDate);
|
||||
}
|
||||
selectingEndDate.value = false;
|
||||
};
|
||||
@@ -188,13 +295,22 @@ const handleManualInputError = message => {
|
||||
};
|
||||
|
||||
const resetDatePicker = () => {
|
||||
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day
|
||||
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead
|
||||
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6));
|
||||
selectedEndDate.value = endOfDay(currentDate.value);
|
||||
// Calculate Last 7 days from today
|
||||
const startDate = startOfDay(subDays(currentDate.value, 6));
|
||||
const endDate = endOfDay(currentDate.value);
|
||||
|
||||
selectedStartDate.value = startDate;
|
||||
selectedEndDate.value = endDate;
|
||||
|
||||
// Position calendar to show the months of Last 7 days
|
||||
// Example: If today is Feb 5, Last 7 days = Jan 30 - Feb 5, so show Jan + Feb
|
||||
startCurrentDate.value = startOfMonth(startDate);
|
||||
endCurrentDate.value = isSameMonth(startDate, endDate)
|
||||
? startOfMonth(addMonths(startDate, 1))
|
||||
: startOfMonth(endDate);
|
||||
selectingEndDate.value = false;
|
||||
selectedRange.value = LAST_7_DAYS;
|
||||
// Reset view modes if they are being used to toggle between different calendar views
|
||||
monthOffset.value = 0;
|
||||
calendarViews.value = { start: WEEK, end: WEEK };
|
||||
};
|
||||
|
||||
@@ -203,26 +319,58 @@ const emitDateRange = () => {
|
||||
useAlert('Please select a valid time range');
|
||||
} else {
|
||||
showDatePicker.value = false;
|
||||
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
|
||||
emit('dateRangeChanged', [
|
||||
selectedStartDate.value,
|
||||
selectedEndDate.value,
|
||||
selectedRange.value,
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
// Called when picker opens - positions calendar to show selected date range
|
||||
// Fixes issue where calendar showed wrong months when loaded from URL params
|
||||
const initializeCalendarMonths = () => {
|
||||
if (selectedStartDate.value && selectedEndDate.value) {
|
||||
startCurrentDate.value = startOfMonth(selectedStartDate.value);
|
||||
endCurrentDate.value = isSameMonth(
|
||||
selectedStartDate.value,
|
||||
selectedEndDate.value
|
||||
)
|
||||
? startOfMonth(addMonths(selectedEndDate.value, 1))
|
||||
: startOfMonth(selectedEndDate.value);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDatePicker = () => {
|
||||
showDatePicker.value = !showDatePicker.value;
|
||||
if (showDatePicker.value) initializeCalendarMonths();
|
||||
};
|
||||
|
||||
const closeDatePicker = () => {
|
||||
showDatePicker.value = false;
|
||||
if (isValid(selectedStartDate.value) && isValid(selectedEndDate.value)) {
|
||||
emitDateRange();
|
||||
} else {
|
||||
showDatePicker.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
|
||||
<div class="relative flex-shrink-0 font-inter">
|
||||
<DatePickerButton
|
||||
:selected-start-date="selectedStartDate"
|
||||
:selected-end-date="selectedEndDate"
|
||||
:selected-range="selectedRange"
|
||||
@open="showDatePicker = !showDatePicker"
|
||||
:show-month-navigation="showMonthNavigation"
|
||||
:can-navigate-next="canNavigateNext"
|
||||
:navigation-label="navigationLabel"
|
||||
@open="toggleDatePicker"
|
||||
@navigate-month="navigateMonth"
|
||||
/>
|
||||
<div
|
||||
v-if="showDatePicker"
|
||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
|
||||
v-on-clickaway="closeDatePicker"
|
||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
|
||||
>
|
||||
<CalendarDateRange
|
||||
:selected-range="selectedRange"
|
||||
|
||||
@@ -46,13 +46,13 @@ const onClickSetView = (type, mode) => {
|
||||
xs
|
||||
icon="i-lucide-chevron-left"
|
||||
class="rtl:rotate-180"
|
||||
@click="onClickPrev(calendarType)"
|
||||
@click.stop="onClickPrev(calendarType)"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="firstButtonLabel"
|
||||
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
|
||||
@click="onClickSetView(calendarType, viewMode)"
|
||||
@click.stop="onClickSetView(calendarType, viewMode)"
|
||||
>
|
||||
{{ firstButtonLabel }}
|
||||
</button>
|
||||
@@ -60,7 +60,7 @@ const onClickSetView = (type, mode) => {
|
||||
v-if="buttonLabel"
|
||||
class="p-0 text-sm font-medium text-center text-n-slate-12"
|
||||
:class="{ 'hover:text-n-brand': viewMode }"
|
||||
@click="onClickSetView(calendarType, YEAR)"
|
||||
@click.stop="onClickSetView(calendarType, YEAR)"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
@@ -71,7 +71,7 @@ const onClickSetView = (type, mode) => {
|
||||
xs
|
||||
icon="i-lucide-chevron-right"
|
||||
class="rtl:rotate-180"
|
||||
@click="onClickNext(calendarType)"
|
||||
@click.stop="onClickNext(calendarType)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+15
-14
@@ -18,24 +18,25 @@ const setDateRange = range => {
|
||||
<template>
|
||||
<div class="w-[200px] flex flex-col items-start">
|
||||
<h4
|
||||
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12"
|
||||
class="w-full px-5 py-4 text-xs font-bold capitalize text-start text-n-slate-10"
|
||||
>
|
||||
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
|
||||
</h4>
|
||||
<div class="flex flex-col items-start w-full">
|
||||
<button
|
||||
v-for="range in dateRanges"
|
||||
:key="range.label"
|
||||
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
|
||||
:class="
|
||||
range.value === selectedRange
|
||||
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
@click="setDateRange(range)"
|
||||
>
|
||||
{{ $t(range.label) }}
|
||||
</button>
|
||||
<template v-for="range in dateRanges" :key="range.label">
|
||||
<div v-if="range.separator" class="w-full border-t border-n-strong" />
|
||||
<button
|
||||
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
|
||||
:class="
|
||||
range.value === selectedRange
|
||||
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
@click="setDateRange(range)"
|
||||
>
|
||||
{{ $t(range.label) }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,7 +23,6 @@ const onClickApply = () => {
|
||||
/>
|
||||
<NextButton
|
||||
sm
|
||||
ghost
|
||||
:label="$t('DATE_PICKER.APPLY_BUTTON')"
|
||||
@click="onClickApply"
|
||||
/>
|
||||
|
||||
@@ -78,7 +78,7 @@ const selectMonth = index => {
|
||||
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
|
||||
index !== activeMonthIndex,
|
||||
}"
|
||||
@click="selectMonth(index)"
|
||||
@click.stop="selectMonth(index)"
|
||||
>
|
||||
{{ month }}
|
||||
</button>
|
||||
|
||||
@@ -77,7 +77,7 @@ const selectYear = year => {
|
||||
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
|
||||
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
|
||||
}"
|
||||
@click="selectYear(year)"
|
||||
@click.stop="selectYear(year)"
|
||||
>
|
||||
{{ year }}
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed } from 'vue';
|
||||
import { dateRanges } from '../helpers/DatePickerHelper';
|
||||
import { format, isSameYear, isValid } from 'date-fns';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedStartDate: Date,
|
||||
@@ -10,9 +12,21 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showMonthNavigation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
canNavigateNext: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
navigationLabel: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['open']);
|
||||
const emit = defineEmits(['open', 'navigateMonth']);
|
||||
|
||||
const formatDateRange = computed(() => {
|
||||
const startDate = props.selectedStartDate;
|
||||
@@ -22,19 +36,15 @@ const formatDateRange = computed(() => {
|
||||
return 'Select a date range';
|
||||
}
|
||||
|
||||
const formatString = isSameYear(startDate, endDate)
|
||||
? 'MMM d' // Same year: "Apr 1"
|
||||
: 'MMM d yyyy'; // Different years: "Apr 1 2025"
|
||||
const crossesYears = !isSameYear(startDate, endDate);
|
||||
|
||||
if (isSameYear(startDate, new Date()) && isSameYear(endDate, new Date())) {
|
||||
// Both dates are in the current year
|
||||
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
|
||||
// Always show years when crossing year boundaries
|
||||
if (crossesYears) {
|
||||
return `${format(startDate, 'MMM d, yyyy')} - ${format(endDate, 'MMM d, yyyy')}`;
|
||||
}
|
||||
// At least one date is not in the current year
|
||||
return `${format(startDate, formatString)} - ${format(
|
||||
endDate,
|
||||
formatString
|
||||
)}`;
|
||||
|
||||
// For same year, always show the year for clarity
|
||||
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`;
|
||||
});
|
||||
|
||||
const activeDateRange = computed(
|
||||
@@ -47,17 +57,46 @@ const openDatePicker = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1"
|
||||
@click="openDatePicker"
|
||||
>
|
||||
<fluent-icon class="text-n-slate-12" icon="calendar" size="16" />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t(activeDateRange) }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-n-slate-11">
|
||||
{{ formatDateRange }}
|
||||
</span>
|
||||
<fluent-icon class="text-n-slate-12" icon="chevron-down" size="14" />
|
||||
</button>
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button
|
||||
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1 flex-shrink-0"
|
||||
@click="openDatePicker"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-calendar-range"
|
||||
class="text-n-slate-11 size-3.5 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12 truncate">
|
||||
{{ navigationLabel || $t(activeDateRange) }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-n-slate-11 truncate">
|
||||
{{ formatDateRange }}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="text-n-slate-12 size-4 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<NextButton
|
||||
v-if="showMonthNavigation"
|
||||
v-tooltip.top="$t('DATE_PICKER.PREVIOUS_PERIOD')"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
icon="i-lucide-chevron-left"
|
||||
class="rtl:rotate-180"
|
||||
@click="emit('navigateMonth', 'prev')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showMonthNavigation"
|
||||
v-tooltip.top="$t('DATE_PICKER.NEXT_PERIOD')"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
icon="i-lucide-chevron-right"
|
||||
class="rtl:rotate-180"
|
||||
:disabled="!canNavigateNext"
|
||||
@click="emit('navigateMonth', 'next')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
isSameMonth,
|
||||
format,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addWeeks,
|
||||
addDays,
|
||||
eachDayOfInterval,
|
||||
endOfMonth,
|
||||
@@ -34,13 +36,27 @@ export const dateRanges = [
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
|
||||
value: 'last3months',
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
|
||||
value: 'last6months',
|
||||
},
|
||||
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' },
|
||||
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE', value: 'custom' },
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.THIS_WEEK',
|
||||
value: 'thisWeek',
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.MONTH_TO_DATE',
|
||||
value: 'monthToDate',
|
||||
},
|
||||
{
|
||||
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE',
|
||||
value: 'custom',
|
||||
separator: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const DATE_RANGE_TYPES = {
|
||||
@@ -49,6 +65,8 @@ export const DATE_RANGE_TYPES = {
|
||||
LAST_3_MONTHS: 'last3months',
|
||||
LAST_6_MONTHS: 'last6months',
|
||||
LAST_YEAR: 'lastYear',
|
||||
THIS_WEEK: 'thisWeek',
|
||||
MONTH_TO_DATE: 'monthToDate',
|
||||
CUSTOM_RANGE: 'custom',
|
||||
};
|
||||
|
||||
@@ -210,6 +228,14 @@ export const getActiveDateRange = (range, currentDate) => {
|
||||
start: startOfDay(subMonths(currentDate, 12)),
|
||||
end: endOfDay(currentDate),
|
||||
}),
|
||||
thisWeek: () => ({
|
||||
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: 1 })),
|
||||
end: endOfDay(currentDate),
|
||||
}),
|
||||
monthToDate: () => ({
|
||||
start: startOfDay(startOfMonth(currentDate)),
|
||||
end: endOfDay(currentDate),
|
||||
}),
|
||||
custom: () => ({ start: currentDate, end: currentDate }),
|
||||
};
|
||||
|
||||
@@ -217,3 +243,48 @@ export const getActiveDateRange = (range, currentDate) => {
|
||||
ranges[range] || (() => ({ start: currentDate, end: currentDate }))
|
||||
)();
|
||||
};
|
||||
|
||||
export const isNavigableRange = rangeType =>
|
||||
rangeType === DATE_RANGE_TYPES.MONTH_TO_DATE ||
|
||||
rangeType === DATE_RANGE_TYPES.THIS_WEEK;
|
||||
|
||||
const WEEK_START = 1; // Monday
|
||||
|
||||
const getWeekRangeAtOffset = (offset, currentDate) => {
|
||||
if (offset === 0) {
|
||||
return {
|
||||
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: WEEK_START })),
|
||||
end: endOfDay(currentDate),
|
||||
};
|
||||
}
|
||||
const targetWeek = addWeeks(currentDate, offset);
|
||||
return {
|
||||
start: startOfDay(startOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
|
||||
end: endOfDay(endOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
|
||||
};
|
||||
};
|
||||
|
||||
const getMonthRangeAtOffset = (offset, currentDate) => {
|
||||
if (offset === 0) {
|
||||
return {
|
||||
start: startOfDay(startOfMonth(currentDate)),
|
||||
end: endOfDay(currentDate),
|
||||
};
|
||||
}
|
||||
const targetMonth = addMonths(currentDate, offset);
|
||||
return {
|
||||
start: startOfDay(startOfMonth(targetMonth)),
|
||||
end: endOfDay(endOfMonth(targetMonth)),
|
||||
};
|
||||
};
|
||||
|
||||
export const getRangeAtOffset = (
|
||||
rangeType,
|
||||
offset,
|
||||
currentDate = new Date()
|
||||
) => {
|
||||
if (rangeType === DATE_RANGE_TYPES.THIS_WEEK) {
|
||||
return getWeekRangeAtOffset(offset, currentDate);
|
||||
}
|
||||
return getMonthRangeAtOffset(offset, currentDate);
|
||||
};
|
||||
|
||||
@@ -34,8 +34,8 @@ const value = defineModel({
|
||||
<input
|
||||
v-model="value"
|
||||
:placeholder="inputPlaceholder"
|
||||
type="text"
|
||||
class="w-full mb-0 text-sm !outline-0 bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
|
||||
type="search"
|
||||
class="w-full mb-0 text-sm !outline-0 !outline-none bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
|
||||
/>
|
||||
</div>
|
||||
<!-- Clear filter button -->
|
||||
|
||||
@@ -3,6 +3,9 @@ import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput
|
||||
import AutomationActionFileInput from './AutomationFileInput.vue';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
|
||||
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||
import NextInput from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -10,6 +13,9 @@ export default {
|
||||
AutomationActionFileInput,
|
||||
WootMessageEditor,
|
||||
NextButton,
|
||||
SingleSelect,
|
||||
MultiSelect,
|
||||
NextInput,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
@@ -40,6 +46,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dropdownMaxHeight: {
|
||||
type: String,
|
||||
default: 'max-h-80',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'],
|
||||
computed: {
|
||||
@@ -69,11 +79,21 @@ export default {
|
||||
return this.actionTypes.find(action => action.key === this.action_name)
|
||||
.inputType;
|
||||
},
|
||||
actionInputStyles() {
|
||||
return {
|
||||
'has-error': this.errorMessage,
|
||||
'is-a-macro': this.isMacro,
|
||||
};
|
||||
actionNameAsSelectModel: {
|
||||
get() {
|
||||
if (!this.action_name) return null;
|
||||
const found = this.actionTypes.find(a => a.key === this.action_name);
|
||||
return found ? { id: found.key, name: found.label } : null;
|
||||
},
|
||||
set(value) {
|
||||
this.action_name = value?.id || value;
|
||||
},
|
||||
},
|
||||
actionTypesAsOptions() {
|
||||
return this.actionTypes.map(a => ({ id: a.key, name: a.label }));
|
||||
},
|
||||
isVerticalLayout() {
|
||||
return ['team_message', 'textarea'].includes(this.inputType);
|
||||
},
|
||||
castMessageVmodel: {
|
||||
get() {
|
||||
@@ -94,203 +114,89 @@ export default {
|
||||
resetAction() {
|
||||
this.$emit('resetAction');
|
||||
},
|
||||
onActionNameChange(value) {
|
||||
this.actionNameAsSelectModel = value;
|
||||
this.resetAction();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter" :class="actionInputStyles">
|
||||
<div class="filter-inputs">
|
||||
<select
|
||||
v-model="action_name"
|
||||
class="action__question"
|
||||
:class="{ 'full-width': !showActionInput }"
|
||||
@change="resetAction()"
|
||||
>
|
||||
<option
|
||||
v-for="attribute in actionTypes"
|
||||
:key="attribute.key"
|
||||
:value="attribute.key"
|
||||
>
|
||||
{{ attribute.label }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="showActionInput" class="filter__answer--wrap">
|
||||
<div v-if="inputType" class="w-full">
|
||||
<div
|
||||
<li class="list-none py-2 first:pt-0 last:pb-0">
|
||||
<div
|
||||
class="flex flex-col gap-2"
|
||||
:class="{ 'animate-wiggle': errorMessage }"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SingleSelect
|
||||
:model-value="actionNameAsSelectModel"
|
||||
:options="actionTypesAsOptions"
|
||||
:dropdown-max-height="dropdownMaxHeight"
|
||||
disable-deselect
|
||||
class="flex-shrink-0"
|
||||
@update:model-value="onActionNameChange"
|
||||
/>
|
||||
<template v-if="showActionInput && !isVerticalLayout">
|
||||
<SingleSelect
|
||||
v-if="inputType === 'search_select'"
|
||||
class="multiselect-wrap--small"
|
||||
>
|
||||
<multiselect
|
||||
v-model="action_params"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:max-height="160"
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div
|
||||
v-model="action_params"
|
||||
:options="dropdownValues"
|
||||
:dropdown-max-height="dropdownMaxHeight"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-else-if="inputType === 'multi_select'"
|
||||
class="multiselect-wrap--small"
|
||||
>
|
||||
<multiselect
|
||||
v-model="action_params"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
multiple
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:max-height="160"
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<input
|
||||
v-model="action_params"
|
||||
:options="dropdownValues"
|
||||
:dropdown-max-height="dropdownMaxHeight"
|
||||
/>
|
||||
<NextInput
|
||||
v-else-if="inputType === 'email'"
|
||||
v-model="action_params"
|
||||
type="email"
|
||||
class="answer--text-input"
|
||||
size="sm"
|
||||
:placeholder="$t('AUTOMATION.ACTION.EMAIL_INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
<input
|
||||
<NextInput
|
||||
v-else-if="inputType === 'url'"
|
||||
v-model="action_params"
|
||||
type="url"
|
||||
class="answer--text-input"
|
||||
size="sm"
|
||||
:placeholder="$t('AUTOMATION.ACTION.URL_INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
<AutomationActionFileInput
|
||||
v-if="inputType === 'attachment'"
|
||||
v-else-if="inputType === 'attachment'"
|
||||
v-model="action_params"
|
||||
:initial-file-name="initialFileName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<NextButton
|
||||
v-if="!isMacro"
|
||||
sm
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
class="flex-shrink-0"
|
||||
@click="removeAction"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
v-if="!isMacro"
|
||||
icon="i-lucide-x"
|
||||
slate
|
||||
ghost
|
||||
class="flex-shrink-0"
|
||||
@click="removeAction"
|
||||
<AutomationActionTeamMessageInput
|
||||
v-if="inputType === 'team_message'"
|
||||
v-model="action_params"
|
||||
:teams="dropdownValues"
|
||||
:dropdown-max-height="dropdownMaxHeight"
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-if="inputType === 'textarea'"
|
||||
v-model="castMessageVmodel"
|
||||
rows="4"
|
||||
enable-variables
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
class="[&_.ProseMirror-menubar]:hidden px-3 py-1 bg-n-alpha-1 rounded-lg outline outline-1 outline-n-weak dark:outline-n-strong"
|
||||
/>
|
||||
</div>
|
||||
<AutomationActionTeamMessageInput
|
||||
v-if="inputType === 'team_message'"
|
||||
v-model="action_params"
|
||||
:teams="dropdownValues"
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-if="inputType === 'textarea'"
|
||||
v-model="castMessageVmodel"
|
||||
rows="4"
|
||||
enable-variables
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
class="action-message"
|
||||
/>
|
||||
<p v-if="errorMessage" class="filter-error">
|
||||
<span v-if="errorMessage" class="text-sm text-n-ruby-11">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter {
|
||||
@apply bg-n-background p-2 border border-solid border-n-strong dark:border-n-strong rounded-lg mb-2;
|
||||
|
||||
&.is-a-macro {
|
||||
@apply mb-0 bg-n-background dark:bg-n-solid-1 p-0 border-0 rounded-none;
|
||||
}
|
||||
}
|
||||
|
||||
.no-margin-bottom {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.filter.has-error {
|
||||
@apply bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5;
|
||||
|
||||
&.is-a-macro {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-inputs {
|
||||
@apply flex gap-1;
|
||||
}
|
||||
|
||||
.filter-error {
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
|
||||
}
|
||||
|
||||
.action__question,
|
||||
.filter__operator {
|
||||
@apply mb-0 mr-1;
|
||||
}
|
||||
|
||||
.action__question {
|
||||
@apply max-w-[50%];
|
||||
}
|
||||
|
||||
.action__question.full-width {
|
||||
@apply max-w-full;
|
||||
}
|
||||
|
||||
.filter__answer--wrap {
|
||||
@apply max-w-[50%] flex-grow mr-1 flex w-full items-center justify-start;
|
||||
|
||||
input {
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
.filter__answer {
|
||||
&.answer--text-input {
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter__join-operator-wrap {
|
||||
@apply relative z-20 m-0;
|
||||
}
|
||||
|
||||
.filter__join-operator {
|
||||
@apply flex items-center justify-center relative my-2.5 mx-0;
|
||||
|
||||
.operator__line {
|
||||
@apply absolute w-full border-b border-solid border-n-weak;
|
||||
}
|
||||
|
||||
.operator__select {
|
||||
margin-bottom: 0 !important;
|
||||
@apply relative w-auto;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
@apply mb-0;
|
||||
}
|
||||
.action-message {
|
||||
@apply mt-2 mx-0 mb-0;
|
||||
}
|
||||
// Prosemirror does not have a native way of hiding the menu bar, hence
|
||||
::v-deep .ProseMirror-menubar {
|
||||
@apply hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script>
|
||||
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MultiSelect,
|
||||
},
|
||||
props: {
|
||||
teams: { type: Array, required: true },
|
||||
modelValue: { type: Object, required: true },
|
||||
dropdownMaxHeight: { type: String, default: 'max-h-80' },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
@@ -12,9 +18,9 @@ export default {
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const { team_ids: teamIds } = this.modelValue;
|
||||
this.selectedTeams = teamIds;
|
||||
this.message = this.modelValue.message;
|
||||
const { team_ids: teamIds, message } = this.modelValue || {};
|
||||
this.selectedTeams = teamIds || [];
|
||||
this.message = message || '';
|
||||
},
|
||||
methods: {
|
||||
updateValue() {
|
||||
@@ -28,37 +34,19 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="multiselect-wrap--small flex flex-col gap-1 mt-1">
|
||||
<multiselect
|
||||
v-model="selectedTeams"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_DROPDOWN_PLACEHOLDER')"
|
||||
multiple
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:max-height="160"
|
||||
:options="teams"
|
||||
:allow-empty="false"
|
||||
@update:model-value="updateValue"
|
||||
/>
|
||||
<textarea
|
||||
v-model="message"
|
||||
rows="4"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
@input="updateValue"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedTeams"
|
||||
:options="teams"
|
||||
:dropdown-max-height="dropdownMaxHeight"
|
||||
@update:model-value="updateValue"
|
||||
/>
|
||||
<textarea
|
||||
v-model="message"
|
||||
class="mb-0 !text-sm"
|
||||
rows="4"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
@input="updateValue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.multiselect {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
textarea {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -79,7 +79,7 @@ input[type='file'] {
|
||||
@apply hidden;
|
||||
}
|
||||
.input-wrapper {
|
||||
@apply flex h-9 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-sm border border-dashed border-n-strong;
|
||||
@apply flex h-8 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-lg border border-dashed border-n-strong;
|
||||
}
|
||||
.success-icon {
|
||||
@apply text-n-teal-9 mr-2;
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
<script>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
name: 'FilterInput',
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
filterAttributes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
inputType: {
|
||||
type: String,
|
||||
default: 'plain_text',
|
||||
},
|
||||
operators: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
dropdownValues: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showQueryOperator: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showUserInput: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
groupedFilters: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
customAttributeType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'removeFilter', 'resetFilter'],
|
||||
computed: {
|
||||
attributeKey: {
|
||||
get() {
|
||||
if (!this.modelValue) return null;
|
||||
return this.modelValue.attribute_key;
|
||||
},
|
||||
set(value) {
|
||||
const payload = this.modelValue || {};
|
||||
this.$emit('update:modelValue', { ...payload, attribute_key: value });
|
||||
},
|
||||
},
|
||||
filterOperator: {
|
||||
get() {
|
||||
if (!this.modelValue) return null;
|
||||
return this.modelValue.filter_operator;
|
||||
},
|
||||
set(value) {
|
||||
const payload = this.modelValue || {};
|
||||
this.$emit('update:modelValue', { ...payload, filter_operator: value });
|
||||
},
|
||||
},
|
||||
values: {
|
||||
get() {
|
||||
if (!this.modelValue) return null;
|
||||
return this.modelValue.values;
|
||||
},
|
||||
set(value) {
|
||||
const payload = this.modelValue || {};
|
||||
this.$emit('update:modelValue', { ...payload, values: value });
|
||||
},
|
||||
},
|
||||
query_operator: {
|
||||
get() {
|
||||
if (!this.modelValue) return null;
|
||||
return this.modelValue.query_operator;
|
||||
},
|
||||
set(value) {
|
||||
const payload = this.modelValue || {};
|
||||
this.$emit('update:modelValue', { ...payload, query_operator: value });
|
||||
},
|
||||
},
|
||||
custom_attribute_type: {
|
||||
get() {
|
||||
if (!this.customAttributeType) return '';
|
||||
return this.customAttributeType;
|
||||
},
|
||||
set() {
|
||||
const payload = this.modelValue || {};
|
||||
this.$emit('update:modelValue', {
|
||||
...payload,
|
||||
custom_attribute_type: this.customAttributeType,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
customAttributeType: {
|
||||
handler(value) {
|
||||
if (
|
||||
value === 'conversation_attribute' ||
|
||||
value === 'contact_attribute'
|
||||
) {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
this.modelValue.custom_attribute_type = this.customAttributeType;
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
} else this.modelValue.custom_attribute_type = '';
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
removeFilter() {
|
||||
this.$emit('removeFilter');
|
||||
},
|
||||
resetFilter() {
|
||||
this.$emit('resetFilter');
|
||||
},
|
||||
getInputErrorClass(errorMessage) {
|
||||
return errorMessage
|
||||
? 'bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5'
|
||||
: 'bg-n-background border-n-weak dark:border-n-weak';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-mutating-props -->
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="p-2 border border-solid rounded-lg"
|
||||
:class="getInputErrorClass(errorMessage)"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<select
|
||||
v-if="groupedFilters"
|
||||
v-model="attributeKey"
|
||||
class="max-w-[30%] mb-0 mr-1"
|
||||
@change="resetFilter()"
|
||||
>
|
||||
<optgroup
|
||||
v-for="(group, i) in filterGroups"
|
||||
:key="i"
|
||||
:label="group.name"
|
||||
>
|
||||
<option
|
||||
v-for="attribute in group.attributes"
|
||||
:key="attribute.key"
|
||||
:value="attribute.key"
|
||||
:selected="true"
|
||||
>
|
||||
{{ attribute.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<select
|
||||
v-else
|
||||
v-model="attributeKey"
|
||||
class="max-w-[30%] mb-0 mr-1"
|
||||
@change="resetFilter()"
|
||||
>
|
||||
<option
|
||||
v-for="attribute in filterAttributes"
|
||||
:key="attribute.key"
|
||||
:value="attribute.key"
|
||||
:disabled="attribute.disabled"
|
||||
>
|
||||
{{ attribute.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-model="filterOperator" class="max-w-[20%] mb-0 mr-1">
|
||||
<option
|
||||
v-for="(operator, o) in operators"
|
||||
:key="o"
|
||||
:value="operator.value"
|
||||
>
|
||||
{{ $t(`FILTER.OPERATOR_LABELS.${operator.value}`) }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div v-if="showUserInput" class="flex-grow mr-1 filter__answer--wrap">
|
||||
<div
|
||||
v-if="inputType === 'multi_select'"
|
||||
class="multiselect-wrap--small"
|
||||
>
|
||||
<multiselect
|
||||
v-model="values"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
multiple
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:max-height="160"
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="inputType === 'search_select'"
|
||||
class="multiselect-wrap--small"
|
||||
>
|
||||
<multiselect
|
||||
v-model="values"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:max-height="160"
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
|
||||
<input
|
||||
v-model="values"
|
||||
type="date"
|
||||
:editable="false"
|
||||
class="!mb-0 datepicker"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
v-model="values"
|
||||
type="text"
|
||||
class="!mb-0"
|
||||
:placeholder="$t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
icon="i-lucide-x"
|
||||
slate
|
||||
ghost
|
||||
class="flex-shrink-0"
|
||||
@click="removeFilter"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="filter-error">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showQueryOperator"
|
||||
class="flex items-center justify-center relative my-2.5 mx-0"
|
||||
>
|
||||
<hr class="absolute w-full border-b border-solid border-n-weak" />
|
||||
<select
|
||||
v-model="query_operator"
|
||||
class="relative w-auto mb-0 bg-n-background text-n-slate-12 border-n-weak"
|
||||
>
|
||||
<option value="and">
|
||||
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.AND') }}
|
||||
</option>
|
||||
<option value="or">
|
||||
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.OR') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter__answer--wrap {
|
||||
input {
|
||||
@apply bg-n-background mb-0 text-n-slate-12 border-n-weak;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-error {
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
@apply mb-0;
|
||||
}
|
||||
</style>
|
||||
@@ -28,7 +28,10 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import {
|
||||
CONVERSATION_EVENTS,
|
||||
CAPTAIN_EVENTS,
|
||||
} from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||
|
||||
import {
|
||||
@@ -86,6 +89,7 @@ const props = defineProps({
|
||||
// are triggered except when this flag is true
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
conversationId: { type: Number, default: null },
|
||||
medium: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
@@ -396,7 +400,14 @@ function openFileBrowser() {
|
||||
}
|
||||
|
||||
function handleCopilotClick() {
|
||||
showSelectionMenu.value = !showSelectionMenu.value;
|
||||
const isOpening = !showSelectionMenu.value;
|
||||
if (isOpening) {
|
||||
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
|
||||
conversationId: props.conversationId,
|
||||
entryPoint: 'inline',
|
||||
});
|
||||
}
|
||||
showSelectionMenu.value = isOpening;
|
||||
}
|
||||
|
||||
function handleClickOutside(event) {
|
||||
@@ -819,7 +830,13 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="editorRoot" class="relative w-full">
|
||||
<div
|
||||
ref="editorRoot"
|
||||
class="relative w-full"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed pointer-events-none': disabled,
|
||||
}"
|
||||
>
|
||||
<TagAgents
|
||||
v-if="showUserMentions && isPrivate"
|
||||
:search-key="mentionSearchKey"
|
||||
|
||||
@@ -122,6 +122,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEditorDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'replaceText',
|
||||
@@ -130,7 +134,7 @@ export default {
|
||||
'selectContentTemplate',
|
||||
'toggleQuotedReply',
|
||||
],
|
||||
setup() {
|
||||
setup(props) {
|
||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||
useUISettings();
|
||||
|
||||
@@ -139,6 +143,9 @@ export default {
|
||||
const keyboardEvents = {
|
||||
'$mod+Alt+KeyA': {
|
||||
action: () => {
|
||||
// Skip if editor is disabled (e.g., WhatsApp 24-hour window expired)
|
||||
if (props.isEditorDisabled) return;
|
||||
|
||||
// TODO: This is really hacky, we need to replace the file picker component with
|
||||
// a custom one, where the logic and the component markup is isolated.
|
||||
// Once we have the custom component, we can remove the hacky logic below.
|
||||
@@ -146,7 +153,7 @@ export default {
|
||||
const uploadTriggerButton = document.querySelector(
|
||||
'#conversationAttachment'
|
||||
);
|
||||
uploadTriggerButton.click();
|
||||
if (uploadTriggerButton) uploadTriggerButton.click();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
@@ -177,9 +184,11 @@ export default {
|
||||
};
|
||||
},
|
||||
showAttachButton() {
|
||||
if (this.isEditorDisabled) return false;
|
||||
return this.showFileUpload || this.isNote;
|
||||
},
|
||||
showAudioRecorderButton() {
|
||||
if (this.isEditorDisabled) return false;
|
||||
if (this.isALineChannel) {
|
||||
return false;
|
||||
}
|
||||
@@ -197,6 +206,7 @@ export default {
|
||||
);
|
||||
},
|
||||
showAudioPlayStopButton() {
|
||||
if (this.isEditorDisabled) return false;
|
||||
return this.showAudioRecorder && this.isRecordingAudio;
|
||||
},
|
||||
isInstagramDM() {
|
||||
@@ -236,6 +246,7 @@ export default {
|
||||
}
|
||||
},
|
||||
showMessageSignatureButton() {
|
||||
if (this.isEditorDisabled) return false;
|
||||
return !this.isOnPrivateNote;
|
||||
},
|
||||
sendWithSignature() {
|
||||
@@ -280,6 +291,7 @@ export default {
|
||||
<div class="flex justify-between p-3" :class="wrapClass">
|
||||
<div class="left-wrap">
|
||||
<NextButton
|
||||
v-if="!isEditorDisabled"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
|
||||
icon="i-ph-smiley-sticker"
|
||||
slate
|
||||
@@ -288,6 +300,7 @@ export default {
|
||||
@click="toggleEmojiPicker"
|
||||
/>
|
||||
<FileUpload
|
||||
v-if="showAttachButton"
|
||||
ref="uploadRef"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
input-id="conversationAttachment"
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import { ref } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import EditorModeToggle from './EditorModeToggle.vue';
|
||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||
@@ -31,6 +33,14 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEditorDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
isMessageLengthReachingThreshold: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
@@ -69,7 +79,14 @@ export default {
|
||||
};
|
||||
|
||||
const toggleCopilotMenu = () => {
|
||||
showCopilotMenu.value = !showCopilotMenu.value;
|
||||
const isOpening = !showCopilotMenu.value;
|
||||
if (isOpening) {
|
||||
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
|
||||
conversationId: props.conversationId,
|
||||
entryPoint: 'top_panel',
|
||||
});
|
||||
}
|
||||
showCopilotMenu.value = isOpening;
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
@@ -144,7 +161,7 @@ export default {
|
||||
<div class="relative">
|
||||
<NextButton
|
||||
ghost
|
||||
:disabled="disabled"
|
||||
:disabled="disabled || isEditorDisabled"
|
||||
:class="{
|
||||
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
|
||||
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
@@ -15,8 +14,6 @@ import PriorityMark from './PriorityMark.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import ConversationSummary from './ConversationSummary.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeLabel: { type: String, default: '' },
|
||||
@@ -37,6 +34,7 @@ const emit = defineEmits([
|
||||
'contextMenuToggle',
|
||||
'assignAgent',
|
||||
'assignLabel',
|
||||
'removeLabel',
|
||||
'assignTeam',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
@@ -49,10 +47,8 @@ const emit = defineEmits([
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const hovered = ref(false);
|
||||
const summaryRef = ref(null);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({
|
||||
x: null,
|
||||
@@ -208,7 +204,10 @@ const onAssignAgent = agent => {
|
||||
|
||||
const onAssignLabel = label => {
|
||||
emit('assignLabel', [label.title], [props.chat.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onRemoveLabel = label => {
|
||||
emit('removeLabel', [label.title], [props.chat.id]);
|
||||
};
|
||||
|
||||
const onAssignTeam = team => {
|
||||
@@ -349,15 +348,12 @@ const deleteConversation = () => {
|
||||
class="absolute flex flex-col ltr:right-3 rtl:left-3"
|
||||
:class="showMetaSection ? 'top-8' : 'top-4'"
|
||||
>
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<ConversationSummary ref="summaryRef" :chat="chat" />
|
||||
<span class="font-normal leading-4 text-xxs">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<span class="ml-auto font-normal leading-4 text-xxs">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
|
||||
:class="hasUnread ? 'block' : 'hidden'"
|
||||
@@ -374,26 +370,6 @@ const deleteConversation = () => {
|
||||
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||
</template>
|
||||
</CardLabels>
|
||||
<!-- Expanded Summary Section -->
|
||||
<div
|
||||
v-if="summaryRef?.isExpanded"
|
||||
class="mt-2 mx-2 mb-1 p-3 bg-n-alpha-1 dark:bg-n-alpha-2 rounded-lg"
|
||||
>
|
||||
<div v-if="summaryRef?.isLoading" class="flex items-center gap-2">
|
||||
<Spinner :size="16" class="text-n-slate-10" />
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ t('CHAT_LIST.SUMMARY.LOADING') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="summaryRef?.error" class="text-xs text-n-ruby-11">
|
||||
{{ summaryRef.error }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="summaryRef?.formattedSummary"
|
||||
class="text-xs text-n-slate-11 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-0.5 [&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_strong]:text-n-slate-12"
|
||||
v-html="summaryRef.formattedSummary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu
|
||||
v-if="showContextMenu"
|
||||
@@ -407,11 +383,13 @@ const deleteConversation = () => {
|
||||
:priority="chat.priority"
|
||||
:chat-id="chat.id"
|
||||
:has-unread-messages="hasUnread"
|
||||
:conversation-labels="chat.labels"
|
||||
:conversation-url="conversationPath"
|
||||
:allowed-options="allowedContextMenuOptions"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@remove-label="onRemoveLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const captainTasksEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
|
||||
});
|
||||
|
||||
const cachedSummary = computed(() => props.chat?.cached_summary || '');
|
||||
const cachedSummaryAt = computed(() => props.chat?.cached_summary_at || 0);
|
||||
const lastActivityAt = computed(() => props.chat?.last_activity_at || 0);
|
||||
|
||||
const isStale = computed(() => {
|
||||
if (!cachedSummaryAt.value) return true;
|
||||
return lastActivityAt.value > cachedSummaryAt.value;
|
||||
});
|
||||
|
||||
const formattedSummary = computed(() => {
|
||||
return cachedSummary.value ? formatMessage(cachedSummary.value) : '';
|
||||
});
|
||||
|
||||
const fetchSummary = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const result = await TasksAPI.summarize(props.chat.id, {
|
||||
forceRegenerate: false,
|
||||
});
|
||||
const {
|
||||
data: { message: generatedSummary },
|
||||
} = result;
|
||||
|
||||
if (generatedSummary) {
|
||||
store.dispatch('updateConversationCachedSummary', {
|
||||
conversationId: props.chat.id,
|
||||
cachedSummary: generatedSummary,
|
||||
cachedSummaryAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError' && e.name !== 'CanceledError') {
|
||||
error.value = e.response?.data?.error || t('CHAT_LIST.SUMMARY.ERROR');
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSummary = async () => {
|
||||
if (isExpanded.value) {
|
||||
isExpanded.value = false;
|
||||
return;
|
||||
}
|
||||
isExpanded.value = true;
|
||||
useTrack(CAPTAIN_EVENTS.SUMMARIZE_USED, {
|
||||
conversationId: props.chat.id,
|
||||
uiFrom: 'conversation_list',
|
||||
});
|
||||
// Only fetch if no cached summary
|
||||
if (!cachedSummary.value && !error.value) {
|
||||
await fetchSummary();
|
||||
}
|
||||
};
|
||||
|
||||
const onButtonClick = e => {
|
||||
e.stopPropagation();
|
||||
toggleSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
isExpanded,
|
||||
isLoading,
|
||||
error,
|
||||
formattedSummary,
|
||||
captainTasksEnabled,
|
||||
isStale,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
v-if="captainTasksEnabled"
|
||||
icon="i-material-symbols-auto-awesome"
|
||||
slate
|
||||
ghost
|
||||
xs
|
||||
:title="t('CHAT_LIST.SUMMARY.TITLE')"
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:class="{ '!opacity-100': isExpanded }"
|
||||
@click="onButtonClick"
|
||||
/>
|
||||
</template>
|
||||
@@ -41,7 +41,10 @@ import {
|
||||
truncatePreviewText,
|
||||
appendQuotedTextToMessage,
|
||||
} from 'dashboard/helper/quotedEmailHelper';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import {
|
||||
CONVERSATION_EVENTS,
|
||||
CAPTAIN_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper/events';
|
||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
appendSignature,
|
||||
@@ -136,6 +139,7 @@ export default {
|
||||
newConversationModalActive: false,
|
||||
showArticleSearchPopover: false,
|
||||
hasRecordedAudio: false,
|
||||
copilotAcceptedMessages: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -195,6 +199,11 @@ export default {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
messagePlaceHolder() {
|
||||
if (this.isEditorDisabled) {
|
||||
return this.isAWhatsAppChannel
|
||||
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
|
||||
: this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
|
||||
}
|
||||
return this.isPrivate
|
||||
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
|
||||
: this.$t('CONVERSATION.FOOTER.MSG_INPUT');
|
||||
@@ -206,6 +215,7 @@ export default {
|
||||
return this.maxLength - this.message.length;
|
||||
},
|
||||
isReplyButtonDisabled() {
|
||||
if (this.isEditorDisabled) return true;
|
||||
if (this.isATwitterInbox) return true;
|
||||
if (this.hasAttachments || this.hasRecordedAudio) return false;
|
||||
|
||||
@@ -414,6 +424,13 @@ export default {
|
||||
isDefaultEditorMode() {
|
||||
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
|
||||
},
|
||||
isEditorDisabled() {
|
||||
return (
|
||||
this.isAWhatsAppChannel &&
|
||||
!this.isOnPrivateNote &&
|
||||
!this.currentChat.can_reply
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation, oldConversation) {
|
||||
@@ -508,6 +525,24 @@ export default {
|
||||
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
|
||||
},
|
||||
methods: {
|
||||
getDraftKey(
|
||||
conversationId = this.conversationIdByRoute,
|
||||
replyType = this.replyType
|
||||
) {
|
||||
return `draft-${conversationId}-${replyType}`;
|
||||
},
|
||||
getCopilotAcceptedMessage(replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
return this.copilotAcceptedMessages[key] || '';
|
||||
},
|
||||
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
this.copilotAcceptedMessages[key] = trimContent(message || '');
|
||||
},
|
||||
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
delete this.copilotAcceptedMessages[key];
|
||||
},
|
||||
handleInsert(article) {
|
||||
const { url, title } = article;
|
||||
// Removing empty lines from the title
|
||||
@@ -559,7 +594,7 @@ export default {
|
||||
},
|
||||
saveDraft(conversationId, replyType) {
|
||||
if (this.message || this.message === '') {
|
||||
const key = `draft-${conversationId}-${replyType}`;
|
||||
const key = this.getDraftKey(conversationId, replyType);
|
||||
const draftToSave = trimContent(this.message || '');
|
||||
|
||||
this.$store.dispatch('draftMessages/set', {
|
||||
@@ -574,7 +609,7 @@ export default {
|
||||
},
|
||||
getFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
const key = this.getDraftKey();
|
||||
const messageFromStore =
|
||||
this.$store.getters['draftMessages/get'](key) || '';
|
||||
|
||||
@@ -597,7 +632,7 @@ export default {
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
const key = this.getDraftKey();
|
||||
this.$store.dispatch('draftMessages/delete', { key });
|
||||
}
|
||||
},
|
||||
@@ -655,6 +690,9 @@ export default {
|
||||
// Don't handle paste if compose new conversation modal is open
|
||||
if (this.newConversationModalActive) return;
|
||||
|
||||
// Don't handle paste if editor is disabled
|
||||
if (this.isEditorDisabled) return;
|
||||
|
||||
// Filter valid files (non-zero size)
|
||||
Array.from(e.clipboardData.files)
|
||||
.filter(file => file.size > 0)
|
||||
@@ -708,6 +746,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
if (!this.showMentions) {
|
||||
const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
|
||||
const isOnWhatsApp =
|
||||
this.isATwilioWhatsAppChannel ||
|
||||
this.isAWhatsAppCloudChannel ||
|
||||
@@ -717,10 +756,17 @@ export default {
|
||||
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
|
||||
const isOnInstagram = this.isAnInstagramChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
||||
this.sendMessageAsMultipleMessages(this.message);
|
||||
this.sendMessageAsMultipleMessages(
|
||||
this.message,
|
||||
copilotAcceptedMessage
|
||||
);
|
||||
} else {
|
||||
const messagePayload = this.getMessagePayload(this.message);
|
||||
this.sendMessage(messagePayload);
|
||||
this.sendMessage(
|
||||
messagePayload,
|
||||
this.message,
|
||||
copilotAcceptedMessage
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.isPrivate) {
|
||||
@@ -732,13 +778,53 @@ export default {
|
||||
this.$emit('update:popOutReplyBox', false);
|
||||
}
|
||||
},
|
||||
sendMessageAsMultipleMessages(message) {
|
||||
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
|
||||
const messages = this.getMultipleMessagesPayload(message);
|
||||
messages.forEach(messagePayload => {
|
||||
this.sendMessage(messagePayload);
|
||||
this.sendMessage(
|
||||
messagePayload,
|
||||
messagePayload.message || '',
|
||||
copilotAcceptedMessage
|
||||
);
|
||||
});
|
||||
},
|
||||
sendMessageAnalyticsData(isPrivate) {
|
||||
sendMessageAnalyticsData(
|
||||
isPrivate,
|
||||
{ editorMessage = '', copilotAcceptedMessage = '' } = {}
|
||||
) {
|
||||
const normalizeForComparison = message => {
|
||||
let normalizedMessage = message || '';
|
||||
|
||||
if (this.sendWithSignature && this.messageSignature && !isPrivate) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
normalizedMessage = removeSignature(
|
||||
normalizedMessage,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
return trimContent(normalizedMessage);
|
||||
};
|
||||
|
||||
const normalizedAcceptedMessage = normalizeForComparison(
|
||||
copilotAcceptedMessage
|
||||
);
|
||||
const normalizedEditorMessage = normalizeForComparison(editorMessage);
|
||||
|
||||
if (normalizedAcceptedMessage && normalizedEditorMessage) {
|
||||
useTrack(CAPTAIN_EVENTS.AI_ASSISTED_MESSAGE_SENT, {
|
||||
conversationId: this.conversationIdByRoute,
|
||||
channelType: this.channelType,
|
||||
editedBeforeSend:
|
||||
normalizedAcceptedMessage !== normalizedEditorMessage,
|
||||
isPrivate,
|
||||
});
|
||||
}
|
||||
|
||||
// Analytics data for message signature is enabled or not in channels
|
||||
return isPrivate
|
||||
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
|
||||
@@ -772,7 +858,11 @@ export default {
|
||||
this.confirmOnSendReply();
|
||||
}
|
||||
},
|
||||
async sendMessage(messagePayload) {
|
||||
async sendMessage(
|
||||
messagePayload,
|
||||
editorMessage = '',
|
||||
copilotAcceptedMessage = ''
|
||||
) {
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
'createPendingMessageAndSend',
|
||||
@@ -781,7 +871,10 @@ export default {
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
|
||||
this.removeFromDraft();
|
||||
this.sendMessageAnalyticsData(messagePayload.private);
|
||||
this.sendMessageAnalyticsData(messagePayload.private, {
|
||||
editorMessage,
|
||||
copilotAcceptedMessage,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
|
||||
@@ -855,6 +948,7 @@ export default {
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
this.clearCopilotAcceptedMessage();
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
@@ -1119,7 +1213,9 @@ export default {
|
||||
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
||||
},
|
||||
onSubmitCopilotReply() {
|
||||
this.message = this.copilot.accept();
|
||||
const acceptedMessage = this.copilot.accept();
|
||||
this.message = acceptedMessage;
|
||||
this.setCopilotAcceptedMessage(acceptedMessage);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1130,11 +1226,13 @@ export default {
|
||||
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
:conversation-id="conversationId"
|
||||
:is-reply-restricted="isReplyRestricted"
|
||||
:disabled="
|
||||
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
|
||||
showAudioRecorderEditor
|
||||
"
|
||||
:is-editor-disabled="isEditorDisabled"
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:popout-reply-box="popOutReplyBox"
|
||||
@@ -1204,12 +1302,14 @@ export default {
|
||||
<WootMessageEditor
|
||||
v-else-if="!showAudioRecorderEditor"
|
||||
v-model="message"
|
||||
:conversation-id="conversationId"
|
||||
:editor-id="editorStateId"
|
||||
class="input popover-prosemirror-menu"
|
||||
:is-private="isOnPrivateNote"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:update-selection-with="updateEditorSelectionWith"
|
||||
:min-height="4"
|
||||
:disabled="isEditorDisabled"
|
||||
enable-variables
|
||||
:variables="messageVariables"
|
||||
:signature="messageSignature"
|
||||
@@ -1285,6 +1385,7 @@ export default {
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
:is-send-disabled="isReplyButtonDisabled"
|
||||
:is-note="isPrivate"
|
||||
:is-editor-disabled="isEditorDisabled"
|
||||
:on-file-upload="onFileUpload"
|
||||
:on-send="onSendReply"
|
||||
:conversation-type="conversationType"
|
||||
|
||||
@@ -31,7 +31,10 @@ const assignedAgent = computed({
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : null;
|
||||
store.dispatch('setCurrentChatAssignee', agent);
|
||||
store.dispatch('setCurrentChatAssignee', {
|
||||
conversationId: currentChat.value?.id,
|
||||
assignee: agent,
|
||||
});
|
||||
store.dispatch('assignAgent', {
|
||||
conversationId: currentChat.value?.id,
|
||||
agentId,
|
||||
|
||||
@@ -53,6 +53,10 @@ export default {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
conversationLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
conversationUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
@@ -70,6 +74,7 @@ export default {
|
||||
'assignAgent',
|
||||
'assignTeam',
|
||||
'assignLabel',
|
||||
'removeLabel',
|
||||
'deleteConversation',
|
||||
'close',
|
||||
],
|
||||
@@ -334,8 +339,16 @@ export default {
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
variant="label"
|
||||
@click.stop="$emit('assignLabel', label)"
|
||||
:variant="
|
||||
conversationLabels.includes(label.title)
|
||||
? 'label-assigned'
|
||||
: 'label'
|
||||
"
|
||||
@click.stop="
|
||||
conversationLabels.includes(label.title)
|
||||
? $emit('removeLabel', label)
|
||||
: $emit('assignLabel', label)
|
||||
"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
option: {
|
||||
@@ -22,7 +23,9 @@ defineProps({
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
v-if="variant === 'label' && option.color"
|
||||
v-if="
|
||||
(variant === 'label' || variant === 'label-assigned') && option.color
|
||||
"
|
||||
class="label-pill flex-shrink-0"
|
||||
:style="{ backgroundColor: option.color }"
|
||||
/>
|
||||
@@ -37,6 +40,11 @@ defineProps({
|
||||
<p class="menu-label truncate min-w-0 flex-1">
|
||||
{{ option.label }}
|
||||
</p>
|
||||
<Icon
|
||||
v-if="variant === 'label-assigned'"
|
||||
icon="i-lucide-check"
|
||||
class="flex-shrink-0 size-3.5 mr-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+6
-2
@@ -2,6 +2,7 @@
|
||||
// components
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
// composables
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
@@ -34,8 +35,9 @@ export default {
|
||||
},
|
||||
setup() {
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
return { captainTasksEnabled };
|
||||
return { captainTasksEnabled, replaceInstallationName };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -228,7 +230,9 @@ export default {
|
||||
<div class="sender--info has-tooltip" data-original-title="null">
|
||||
<Avatar
|
||||
v-tooltip.top="{
|
||||
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
|
||||
content: replaceInstallationName(
|
||||
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
|
||||
),
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const CAPTAIN_ERROR_TYPES = Object.freeze({
|
||||
ABORTED: 'aborted',
|
||||
API_ERROR: 'api_error',
|
||||
HTTP_PREFIX: 'http_',
|
||||
ABORT_ERROR: 'AbortError',
|
||||
CANCELED_ERROR: 'CanceledError',
|
||||
});
|
||||
|
||||
export const CAPTAIN_GENERATION_FAILURE_REASONS = Object.freeze({
|
||||
EMPTY_RESPONSE: 'empty_response',
|
||||
EXCEPTION: 'exception',
|
||||
});
|
||||
@@ -102,6 +102,28 @@ export function useBulkActions() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only used in context menu
|
||||
async function onRemoveLabels(labelsToRemove, conversationId = null) {
|
||||
try {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: conversationId || selectedConversations.value,
|
||||
labels: {
|
||||
remove: labelsToRemove,
|
||||
},
|
||||
});
|
||||
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
|
||||
labelName: labelsToRemove[0],
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
|
||||
}
|
||||
}
|
||||
|
||||
async function onAssignTeamsForBulk(team) {
|
||||
try {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
@@ -189,6 +211,7 @@ export function useBulkActions() {
|
||||
isConversationSelected,
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onRemoveLabels,
|
||||
onAssignTeamsForBulk,
|
||||
onUpdateConversations,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -40,7 +40,7 @@ export function useAutomation(startValue = null) {
|
||||
} = useAutomationValues();
|
||||
|
||||
const automation = ref(startValue);
|
||||
const automationTypes = structuredClone(AUTOMATIONS);
|
||||
const automationTypes = reactive(structuredClone(AUTOMATIONS));
|
||||
const eventName = computed(() => automation.value?.event_name);
|
||||
|
||||
/**
|
||||
@@ -160,14 +160,24 @@ export function useAutomation(startValue = null) {
|
||||
t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL')
|
||||
);
|
||||
|
||||
const CUSTOM_ATTR_HEADER_KEYS = new Set([
|
||||
'conversation_custom_attribute',
|
||||
'contact_custom_attribute',
|
||||
]);
|
||||
|
||||
[
|
||||
'message_created',
|
||||
'conversation_created',
|
||||
'conversation_updated',
|
||||
'conversation_opened',
|
||||
].forEach(eventToUpdate => {
|
||||
const standardConditions = automationTypes[
|
||||
eventToUpdate
|
||||
].conditions.filter(
|
||||
c => !c.customAttributeType && !CUSTOM_ATTR_HEADER_KEYS.has(c.key)
|
||||
);
|
||||
automationTypes[eventToUpdate].conditions = [
|
||||
...automationTypes[eventToUpdate].conditions,
|
||||
...standardConditions,
|
||||
...manifestedCustomAttributes,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
import { CAPTAIN_ERROR_TYPES } from 'dashboard/composables/captain/constants';
|
||||
|
||||
export function useCaptain() {
|
||||
const store = useStore();
|
||||
@@ -69,7 +70,10 @@ export function useCaptain() {
|
||||
* @param {Error} error - The error object from the API call.
|
||||
*/
|
||||
const handleAPIError = error => {
|
||||
if (error.name === 'AbortError' || error.name === 'CanceledError') {
|
||||
if (
|
||||
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const errorMessage =
|
||||
@@ -78,6 +82,24 @@ export function useCaptain() {
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Classifies API error types for downstream analytics.
|
||||
* @param {Error} error
|
||||
* @returns {string}
|
||||
*/
|
||||
const getErrorType = error => {
|
||||
if (
|
||||
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||
) {
|
||||
return CAPTAIN_ERROR_TYPES.ABORTED;
|
||||
}
|
||||
if (error.response?.status) {
|
||||
return `${CAPTAIN_ERROR_TYPES.HTTP_PREFIX}${error.response.status}`;
|
||||
}
|
||||
return CAPTAIN_ERROR_TYPES.API_ERROR;
|
||||
};
|
||||
|
||||
// === Task Methods ===
|
||||
/**
|
||||
* Rewrites content with a specific operation.
|
||||
@@ -103,7 +125,7 @@ export function useCaptain() {
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
return { message: '', errorType: getErrorType(error) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,7 +147,7 @@ export function useCaptain() {
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
return { message: '', errorType: getErrorType(error) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,7 +169,7 @@ export function useCaptain() {
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
return { message: '', errorType: getErrorType(error) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -171,7 +193,11 @@ export function useCaptain() {
|
||||
return { message: generatedMessage, followUpContext: updatedContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '', followUpContext };
|
||||
return {
|
||||
message: '',
|
||||
followUpContext,
|
||||
errorType: getErrorType(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import {
|
||||
CAPTAIN_ERROR_TYPES,
|
||||
CAPTAIN_GENERATION_FAILURE_REASONS,
|
||||
} from 'dashboard/composables/captain/constants';
|
||||
|
||||
// Actions that map to REWRITE events (with operation attribute)
|
||||
const REWRITE_ACTIONS = [
|
||||
@@ -34,21 +38,11 @@ function getEventPrefix(action) {
|
||||
* @param {string} action - The action type
|
||||
* @param {number} conversationId - The conversation ID
|
||||
* @param {number} [followUpCount] - Optional follow-up count
|
||||
* @param {string} [uiFrom] - Optional UI source identifier
|
||||
* @returns {Object} The payload object
|
||||
*/
|
||||
function buildPayload(
|
||||
action,
|
||||
conversationId,
|
||||
followUpCount = undefined,
|
||||
uiFrom = undefined
|
||||
) {
|
||||
function buildPayload(action, conversationId, followUpCount = undefined) {
|
||||
const payload = { conversationId };
|
||||
|
||||
if (uiFrom) {
|
||||
payload.uiFrom = uiFrom;
|
||||
}
|
||||
|
||||
// Add operation for rewrite actions
|
||||
if (REWRITE_ACTIONS.includes(action)) {
|
||||
payload.operation = action;
|
||||
@@ -62,6 +56,20 @@ function buildPayload(
|
||||
return payload;
|
||||
}
|
||||
|
||||
function trackGenerationFailure({
|
||||
action,
|
||||
conversationId,
|
||||
followUpCount = undefined,
|
||||
stage,
|
||||
reason,
|
||||
}) {
|
||||
useTrack(CAPTAIN_EVENTS.GENERATION_FAILED, {
|
||||
...buildPayload(action, conversationId, followUpCount),
|
||||
stage,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing Copilot reply generation state and actions.
|
||||
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
|
||||
@@ -85,7 +93,6 @@ export function useCopilotReply() {
|
||||
const trackedConversationId = ref(null);
|
||||
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
const uiFrom = 'editor';
|
||||
|
||||
const isActive = computed(() => showEditor.value || isGenerating.value);
|
||||
const isButtonDisabled = computed(
|
||||
@@ -108,8 +115,7 @@ export function useCopilotReply() {
|
||||
buildPayload(
|
||||
currentAction.value,
|
||||
trackedConversationId.value,
|
||||
followUpCount.value,
|
||||
uiFrom
|
||||
followUpCount.value
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -158,7 +164,8 @@ export function useCopilotReply() {
|
||||
|
||||
// Reset without tracking dismiss (starting new action)
|
||||
reset(false);
|
||||
abortController.value = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
abortController.value = requestController;
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
currentAction.value = action;
|
||||
@@ -166,28 +173,66 @@ export function useCopilotReply() {
|
||||
trackedConversationId.value = conversationId.value;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: newContext } =
|
||||
await processEvent(action, data, {
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
const {
|
||||
message: content,
|
||||
followUpContext: newContext,
|
||||
errorType,
|
||||
} = await processEvent(action, data, {
|
||||
signal: requestController.signal,
|
||||
});
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = newContext;
|
||||
if (content) {
|
||||
showEditor.value = true;
|
||||
// Track "Used" event on successful generation
|
||||
const eventKey = `${getEventPrefix(action)}_USED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(action, trackedConversationId.value, undefined, uiFrom)
|
||||
);
|
||||
if (requestController.signal.aborted) return;
|
||||
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||
if (abortController.value === requestController) {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
isGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
isGenerating.value = false;
|
||||
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = newContext;
|
||||
if (content) {
|
||||
showEditor.value = true;
|
||||
// Track "Used" event on successful generation
|
||||
const eventKey = `${getEventPrefix(action)}_USED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(action, trackedConversationId.value)
|
||||
);
|
||||
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||
trackGenerationFailure({
|
||||
action,
|
||||
conversationId: trackedConversationId.value,
|
||||
stage: 'initial',
|
||||
reason: errorType,
|
||||
});
|
||||
} else {
|
||||
trackGenerationFailure({
|
||||
action,
|
||||
conversationId: trackedConversationId.value,
|
||||
stage: 'initial',
|
||||
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
|
||||
});
|
||||
}
|
||||
isGenerating.value = false;
|
||||
} catch (error) {
|
||||
if (
|
||||
requestController.signal.aborted ||
|
||||
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||
) {
|
||||
return;
|
||||
}
|
||||
trackGenerationFailure({
|
||||
action,
|
||||
conversationId: trackedConversationId.value,
|
||||
stage: 'initial',
|
||||
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
|
||||
});
|
||||
isGenerating.value = false;
|
||||
} finally {
|
||||
if (abortController.value === requestController) {
|
||||
abortController.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,36 +244,77 @@ export function useCopilotReply() {
|
||||
async function sendFollowUp(message) {
|
||||
if (!followUpContext.value || !message.trim()) return;
|
||||
|
||||
abortController.value = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
abortController.value = requestController;
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
|
||||
// Track follow-up sent event
|
||||
useTrack(CAPTAIN_EVENTS.FOLLOW_UP_SENT, {
|
||||
conversationId: trackedConversationId.value,
|
||||
uiFrom,
|
||||
});
|
||||
followUpCount.value += 1;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: updatedContext } =
|
||||
await followUp({
|
||||
followUpContext: followUpContext.value,
|
||||
message,
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
const {
|
||||
message: content,
|
||||
followUpContext: updatedContext,
|
||||
errorType,
|
||||
} = await followUp({
|
||||
followUpContext: followUpContext.value,
|
||||
message,
|
||||
signal: requestController.signal,
|
||||
});
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
if (content) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = updatedContext;
|
||||
showEditor.value = true;
|
||||
if (requestController.signal.aborted) return;
|
||||
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||
if (abortController.value === requestController) {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
isGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
isGenerating.value = false;
|
||||
|
||||
if (content) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = updatedContext;
|
||||
showEditor.value = true;
|
||||
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||
trackGenerationFailure({
|
||||
action: currentAction.value,
|
||||
conversationId: trackedConversationId.value,
|
||||
followUpCount: followUpCount.value,
|
||||
stage: 'follow_up',
|
||||
reason: errorType,
|
||||
});
|
||||
} else {
|
||||
trackGenerationFailure({
|
||||
action: currentAction.value,
|
||||
conversationId: trackedConversationId.value,
|
||||
followUpCount: followUpCount.value,
|
||||
stage: 'follow_up',
|
||||
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
|
||||
});
|
||||
}
|
||||
isGenerating.value = false;
|
||||
} catch (error) {
|
||||
if (
|
||||
requestController.signal.aborted ||
|
||||
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||
) {
|
||||
return;
|
||||
}
|
||||
trackGenerationFailure({
|
||||
action: currentAction.value,
|
||||
conversationId: trackedConversationId.value,
|
||||
followUpCount: followUpCount.value,
|
||||
stage: 'follow_up',
|
||||
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
|
||||
});
|
||||
isGenerating.value = false;
|
||||
} finally {
|
||||
if (abortController.value === requestController) {
|
||||
abortController.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,8 +336,7 @@ export function useCopilotReply() {
|
||||
buildPayload(
|
||||
currentAction.value,
|
||||
trackedConversationId.value,
|
||||
followUpCount.value,
|
||||
uiFrom
|
||||
followUpCount.value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { computed } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
|
||||
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||
{ name: 'conversation_summary' },
|
||||
{ name: 'conversation_actions' },
|
||||
{ name: 'macros' },
|
||||
{ name: 'conversation_info' },
|
||||
|
||||
@@ -2,6 +2,7 @@ export const FEATURE_FLAGS = {
|
||||
AGENT_BOTS: 'agent_bots',
|
||||
AGENT_MANAGEMENT: 'agent_management',
|
||||
ASSIGNMENT_V2: 'assignment_v2',
|
||||
ADVANCED_ASSIGNMENT: 'advanced_assignment',
|
||||
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
|
||||
AUTOMATIONS: 'automations',
|
||||
CAMPAIGNS: 'campaigns',
|
||||
@@ -56,4 +57,5 @@ export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
FEATURE_FLAGS.SAML,
|
||||
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
|
||||
FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||
];
|
||||
|
||||
@@ -85,6 +85,11 @@ export const PORTALS_EVENTS = Object.freeze({
|
||||
});
|
||||
|
||||
export const CAPTAIN_EVENTS = Object.freeze({
|
||||
// Editor funnel events
|
||||
EDITOR_AI_MENU_OPENED: 'Captain: Editor AI menu opened',
|
||||
GENERATION_FAILED: 'Captain: Generation failed',
|
||||
AI_ASSISTED_MESSAGE_SENT: 'Captain: AI-assisted message sent',
|
||||
|
||||
// Rewrite events (with operation attribute in payload)
|
||||
REWRITE_USED: 'Captain: Rewrite used',
|
||||
REWRITE_APPLIED: 'Captain: Rewrite applied',
|
||||
|
||||
@@ -169,19 +169,19 @@ export const getFileName = (action, files = []) => {
|
||||
|
||||
export const getDefaultConditions = eventName => {
|
||||
if (eventName === 'message_created') {
|
||||
return DEFAULT_MESSAGE_CREATED_CONDITION;
|
||||
return structuredClone(DEFAULT_MESSAGE_CREATED_CONDITION);
|
||||
}
|
||||
if (
|
||||
eventName === 'conversation_opened' ||
|
||||
eventName === 'conversation_resolved'
|
||||
) {
|
||||
return DEFAULT_CONVERSATION_CONDITION;
|
||||
return structuredClone(DEFAULT_CONVERSATION_CONDITION);
|
||||
}
|
||||
return DEFAULT_OTHER_CONDITION;
|
||||
return structuredClone(DEFAULT_OTHER_CONDITION);
|
||||
};
|
||||
|
||||
export const getDefaultActions = () => {
|
||||
return DEFAULT_ACTIONS;
|
||||
return structuredClone(DEFAULT_ACTIONS);
|
||||
};
|
||||
|
||||
export const filterCustomAttributes = customAttributes => {
|
||||
|
||||
@@ -137,11 +137,6 @@
|
||||
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
|
||||
"SHOW_QUOTED_TEXT": "Show Quoted Text",
|
||||
"MESSAGE_READ": "Read",
|
||||
"SENDING": "Sending",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Generate Summary",
|
||||
"LOADING": "Generating summary...",
|
||||
"ERROR": "Failed to generate summary"
|
||||
}
|
||||
"SENDING": "Sending"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,10 @@
|
||||
"SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}",
|
||||
"FAILED": "Couldn't assign label. Please try again."
|
||||
},
|
||||
"LABEL_REMOVAL": {
|
||||
"SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}",
|
||||
"FAILED": "Couldn't remove label. Please try again."
|
||||
},
|
||||
"TEAM_ASSIGNMENT": {
|
||||
"SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}",
|
||||
"FAILED": "Couldn't assign team. Please try again."
|
||||
@@ -186,6 +190,8 @@
|
||||
"DISABLE_SIGN_TOOLTIP": "Disable signature",
|
||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
@@ -349,7 +355,6 @@
|
||||
"CONVERSATION_ACTIONS": "Conversation Actions",
|
||||
"CONVERSATION_LABELS": "Conversation Labels",
|
||||
"CONVERSATION_INFO": "Conversation Information",
|
||||
"CONVERSATION_SUMMARY": "AI Summary",
|
||||
"CONTACT_NOTES": "Contact Notes",
|
||||
"CONTACT_ATTRIBUTES": "Contact Attributes",
|
||||
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
||||
@@ -357,16 +362,6 @@
|
||||
"LINEAR_ISSUES": "Linked Linear Issues",
|
||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||
},
|
||||
"SUMMARY": {
|
||||
"DESCRIPTION": "Generate an AI-powered summary of this conversation",
|
||||
"GENERATE": "Generate Summary",
|
||||
"REGENERATE": "Regenerate",
|
||||
"REFRESH": "Refresh",
|
||||
"RETRY": "Retry",
|
||||
"STALE": "Summary may be outdated",
|
||||
"ERROR": "Failed to generate summary",
|
||||
"EMPTY": "No summary available"
|
||||
},
|
||||
"SHOPIFY": {
|
||||
"ORDER_ID": "Order #{id}",
|
||||
"ERROR": "Error loading orders",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"DATE_PICKER": {
|
||||
"PREVIOUS_PERIOD": "Previous period",
|
||||
"NEXT_PERIOD": "Next period",
|
||||
"WEEK_NUMBER": "Week #{weekNumber}",
|
||||
"APPLY_BUTTON": "Apply",
|
||||
"CLEAR_BUTTON": "Clear",
|
||||
"DATE_RANGE_INPUT": {
|
||||
@@ -13,6 +16,8 @@
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
"LAST_YEAR": "Last year",
|
||||
"THIS_WEEK": "This week",
|
||||
"MONTH_TO_DATE": "This month",
|
||||
"CUSTOM_RANGE": "Custom date range"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -766,6 +766,53 @@
|
||||
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0",
|
||||
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
|
||||
},
|
||||
"ASSIGNMENT": {
|
||||
"TITLE": "Conversation Assignment",
|
||||
"DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies",
|
||||
"ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment",
|
||||
"DEFAULT_RULES_TITLE": "Default assignment rules",
|
||||
"DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations",
|
||||
"DEFAULT_RULE_1": "Earliest created conversations first",
|
||||
"DEFAULT_RULE_2": "Round robin distribution",
|
||||
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
|
||||
"USING_POLICY": "Using custom assignment policy for this inbox",
|
||||
"CUSTOMIZE_POLICY": "Customize with assignment policy",
|
||||
"DELETE_POLICY": "Delete policy",
|
||||
"POLICY_LABEL": "Assignment policy",
|
||||
"ASSIGNMENT_ORDER_LABEL": "Assignment Order",
|
||||
"ASSIGNMENT_METHOD_LABEL": "Assignment Method",
|
||||
"POLICY_STATUS": {
|
||||
"ACTIVE": "Active",
|
||||
"INACTIVE": "Inactive"
|
||||
},
|
||||
"PRIORITY": {
|
||||
"EARLIEST_CREATED": "Earliest created",
|
||||
"LONGEST_WAITING": "Longest waiting"
|
||||
},
|
||||
"METHOD": {
|
||||
"ROUND_ROBIN": "Round robin",
|
||||
"BALANCED": "Balanced assignment"
|
||||
},
|
||||
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
|
||||
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
|
||||
"DEFAULT_POLICY_LINKED": "Default policy linked",
|
||||
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
|
||||
"LINK_EXISTING_POLICY": "Link existing policy",
|
||||
"CREATE_NEW_POLICY": "Create new policy",
|
||||
"NO_POLICIES": "No assignment policies found",
|
||||
"VIEW_ALL_POLICIES": "View all policies",
|
||||
"CURRENT_BEHAVIOR": "Currently using default assignment behavior:",
|
||||
"LINK_SUCCESS": "Assignment policy linked successfully",
|
||||
"LINK_ERROR": "Failed to link assignment policy"
|
||||
},
|
||||
"ASSIGNMENT_POLICY": {
|
||||
"DELETE_CONFIRM_TITLE": "Delete assignment policy?",
|
||||
"DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.",
|
||||
"CANCEL": "Cancel",
|
||||
"CONFIRM_DELETE": "Delete",
|
||||
"DELETE_SUCCESS": "Assignment policy removed successfully",
|
||||
"DELETE_ERROR": "Failed to remove assignment policy"
|
||||
},
|
||||
"FACEBOOK_REAUTHORIZE": {
|
||||
"TITLE": "Reauthorize",
|
||||
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
|
||||
|
||||
@@ -128,11 +128,16 @@
|
||||
},
|
||||
"AGENT_REPORTS": {
|
||||
"HEADER": "Agents Overview",
|
||||
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent’s name to learn more.",
|
||||
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent's name to learn more.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Agent",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"AGENTS": "Search agents"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -201,6 +206,11 @@
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Label",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"LABELS": "Search labels"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -271,6 +281,11 @@
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"INBOXES": "Search inboxes"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -334,11 +349,19 @@
|
||||
},
|
||||
"TEAM_REPORTS": {
|
||||
"HEADER": "Team Overview",
|
||||
"DESCRIPTION": "Get a snapshot of your team’s performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
|
||||
"DESCRIPTION": "Get a snapshot of your team's performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Team",
|
||||
"FILTERS": {
|
||||
"ADD_FILTER": "Add filter",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"NO_FILTER": "No filters available",
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"TEAMS": "Search teams"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
|
||||
@@ -694,7 +694,8 @@
|
||||
"CREATE_BUTTON": "Create policy",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Assignment policy created successfully",
|
||||
"ERROR_MESSAGE": "Failed to create assignment policy"
|
||||
"ERROR_MESSAGE": "Failed to create assignment policy",
|
||||
"INBOX_LINKED": "Inbox has been linked to the policy"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
@@ -708,6 +709,12 @@
|
||||
"CONFIRM_BUTTON_LABEL": "Continue",
|
||||
"CANCEL_BUTTON_LABEL": "Cancel"
|
||||
},
|
||||
"INBOX_LINK_PROMPT": {
|
||||
"TITLE": "Link inbox to policy",
|
||||
"DESCRIPTION": "Would you like to link this inbox to the assignment policy?",
|
||||
"LINK_BUTTON": "Link inbox",
|
||||
"CANCEL_BUTTON": "Skip"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Assignment policy updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update assignment policy"
|
||||
@@ -746,7 +753,9 @@
|
||||
},
|
||||
"BALANCED": {
|
||||
"LABEL": "Balanced",
|
||||
"DESCRIPTION": "Assign conversations based on available capacity."
|
||||
"DESCRIPTION": "Assign conversations based on available capacity.",
|
||||
"PREMIUM_MESSAGE": "Upgrade to access balanced assignment and agent capacity management.",
|
||||
"PREMIUM_BADGE": "Premium"
|
||||
}
|
||||
},
|
||||
"ASSIGNMENT_PRIORITY": {
|
||||
@@ -832,6 +841,20 @@
|
||||
"SUCCESS_MESSAGE": "Agent removed from policy successfully",
|
||||
"ERROR_MESSAGE": "Failed to remove agent from policy"
|
||||
}
|
||||
},
|
||||
"INBOX_LIMIT_API": {
|
||||
"ADD": {
|
||||
"SUCCESS_MESSAGE": "Inbox limit added successfully",
|
||||
"ERROR_MESSAGE": "Failed to add inbox limit"
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS_MESSAGE": "Inbox limit updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update inbox limit"
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS_MESSAGE": "Inbox limit deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete inbox limit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FORM": {
|
||||
|
||||
@@ -1,91 +1,101 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import MergeContact from 'dashboard/modules/contact/components/MergeContact.vue';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
import { mapGetters } from 'vuex';
|
||||
import { CONTACTS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
components: { MergeContact },
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
primaryContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['close', 'update:show'],
|
||||
data() {
|
||||
return {
|
||||
isSearching: false,
|
||||
searchResults: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'contacts/getUIFlags',
|
||||
}),
|
||||
localShow: {
|
||||
get() {
|
||||
return this.show;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:show', value);
|
||||
},
|
||||
},
|
||||
const props = defineProps({
|
||||
primaryContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
async onContactSearch(query) {
|
||||
this.isSearching = true;
|
||||
this.searchResults = [];
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(query);
|
||||
this.searchResults = payload.filter(
|
||||
contact => contact.id !== this.primaryContact.id
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(this.$t('MERGE_CONTACTS.SEARCH.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
this.isSearching = false;
|
||||
}
|
||||
},
|
||||
async onMergeContacts(parentContactId) {
|
||||
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
|
||||
try {
|
||||
await this.$store.dispatch('contacts/merge', {
|
||||
childId: this.primaryContact.id,
|
||||
parentId: parentContactId,
|
||||
});
|
||||
useAlert(this.$t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
useAlert(this.$t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
},
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const isSearching = ref(false);
|
||||
const searchResults = ref([]);
|
||||
|
||||
watch(
|
||||
() => props.primaryContact.id,
|
||||
() => {
|
||||
isSearching.value = false;
|
||||
searchResults.value = [];
|
||||
}
|
||||
);
|
||||
|
||||
const open = () => {
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
|
||||
const onClose = () => {
|
||||
close();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onContactSearch = async query => {
|
||||
isSearching.value = true;
|
||||
searchResults.value = [];
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(query);
|
||||
searchResults.value = payload.filter(
|
||||
contact => contact.id !== props.primaryContact.id
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(t('MERGE_CONTACTS.SEARCH.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onMergeContacts = async parentContactId => {
|
||||
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
|
||||
try {
|
||||
await store.dispatch('contacts/merge', {
|
||||
childId: props.primaryContact.id,
|
||||
parentId: parentContactId,
|
||||
});
|
||||
useAlert(t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
|
||||
close();
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
useAlert(t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal v-model:show="localShow" :on-close="onClose">
|
||||
<woot-modal-header
|
||||
:header-title="$t('MERGE_CONTACTS.TITLE')"
|
||||
:header-content="$t('MERGE_CONTACTS.DESCRIPTION')"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
width="2xl"
|
||||
:title="$t('MERGE_CONTACTS.TITLE')"
|
||||
:description="$t('MERGE_CONTACTS.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
>
|
||||
<MergeContact
|
||||
:key="primaryContact.id"
|
||||
:primary-contact="primaryContact"
|
||||
:is-searching="isSearching"
|
||||
:is-merging="uiFlags.isMerging"
|
||||
@@ -94,5 +104,5 @@ export default {
|
||||
@cancel="onClose"
|
||||
@submit="onMergeContacts"
|
||||
/>
|
||||
</woot-modal>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<script setup>
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
thumbnail: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
phoneNumber: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
identifier: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="option-item--user">
|
||||
<Avatar :src="thumbnail" :size="28" :name="name" rounded-full />
|
||||
<div class="option__user-data">
|
||||
<h5 class="option__title">
|
||||
{{ name }}
|
||||
<span v-if="identifier" class="user-identifier">
|
||||
{{ $t('MERGE_CONTACTS.DROPDOWN_ITEM.ID', { identifier }) }}
|
||||
</span>
|
||||
</h5>
|
||||
<p class="option__body">
|
||||
<span v-if="email" class="email-icon-wrap">
|
||||
<fluent-icon class="merge-contact--icon" icon="mail" size="12" />
|
||||
{{ email }}
|
||||
</span>
|
||||
<span v-if="phoneNumber" class="phone-icon-wrap">
|
||||
<fluent-icon class="merge-contact--icon" icon="call" size="12" />
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
<span v-if="!phoneNumber && !email">{{ '---' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.option-item--user {
|
||||
@apply flex items-center;
|
||||
}
|
||||
.user-identifier {
|
||||
@apply text-xs ml-0.5 text-n-slate-12;
|
||||
}
|
||||
.option__user-data {
|
||||
@apply flex flex-col flex-grow ml-2 mr-2;
|
||||
}
|
||||
.option__body,
|
||||
.option__title {
|
||||
@apply flex items-center justify-start leading-[1.2] text-sm;
|
||||
}
|
||||
.option__body .icon {
|
||||
@apply relative top-px mr-0.5 rtl:mr-0 rtl:ml-0.5;
|
||||
}
|
||||
.option__title {
|
||||
@apply text-n-slate-12 font-medium mb-0.5;
|
||||
}
|
||||
.option__body {
|
||||
@apply text-xs text-n-slate-12 mt-1;
|
||||
}
|
||||
|
||||
.option__user-data .option__body {
|
||||
> .phone-icon-wrap,
|
||||
> .email-icon-wrap {
|
||||
@apply w-auto flex items-center;
|
||||
}
|
||||
}
|
||||
|
||||
.merge-contact--icon {
|
||||
@apply -mb-0.5 mr-0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,174 +1,105 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import MergeContactSummary from 'dashboard/modules/contact/components/MergeContactSummary.vue';
|
||||
import ContactDropdownItem from './ContactDropdownItem.vue';
|
||||
import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: { MergeContactSummary, ContactDropdownItem, NextButton },
|
||||
props: {
|
||||
primaryContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMerging: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
const props = defineProps({
|
||||
primaryContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
emits: ['search', 'submit', 'cancel'],
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
validations: {
|
||||
primaryContact: {
|
||||
required,
|
||||
},
|
||||
parentContact: {
|
||||
required,
|
||||
},
|
||||
isMerging: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
parentContact: undefined,
|
||||
};
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
computed: {
|
||||
parentContactName() {
|
||||
return this.parentContact ? this.parentContact.name : '';
|
||||
const emit = defineEmits(['search', 'submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const parentContactId = ref(null);
|
||||
|
||||
const validationRules = {
|
||||
parentContactId: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, { parentContactId });
|
||||
|
||||
const parentContact = computed(() => {
|
||||
if (!parentContactId.value) return null;
|
||||
return props.searchResults.find(
|
||||
contact => contact.id === parentContactId.value
|
||||
);
|
||||
});
|
||||
|
||||
const parentContactName = computed(() => {
|
||||
return parentContact.value ? parentContact.value.name : '';
|
||||
});
|
||||
|
||||
const primaryContactList = computed(() => {
|
||||
return props.searchResults.map(contact => ({
|
||||
id: contact.id,
|
||||
label: contact.name,
|
||||
value: contact.id,
|
||||
meta: {
|
||||
thumbnail: contact.thumbnail,
|
||||
email: contact.email,
|
||||
phoneNumber: contact.phone_number,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
searchChange(query) {
|
||||
this.$emit('search', query);
|
||||
},
|
||||
onSubmit() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
return;
|
||||
}
|
||||
this.$emit('submit', this.parentContact.id);
|
||||
},
|
||||
onCancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const hasValidationError = computed(() => v$.value.parentContactId.$error);
|
||||
const validationErrorMessage = computed(() => {
|
||||
if (v$.value.parentContactId.$error) {
|
||||
return t('MERGE_CONTACTS.FORM.CHILD_CONTACT.ERROR');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const onSearch = query => {
|
||||
emit('search', query);
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) {
|
||||
return;
|
||||
}
|
||||
emit('submit', parentContactId.value);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
class="mt-1 multiselect-wrap--medium"
|
||||
:class="{ error: v$.parentContact.$error }"
|
||||
>
|
||||
<label class="multiselect__label">
|
||||
{{ $t('MERGE_CONTACTS.PARENT.TITLE') }}
|
||||
<woot-label
|
||||
:title="$t('MERGE_CONTACTS.PARENT.HELP_LABEL')"
|
||||
color-scheme="success"
|
||||
small
|
||||
class="ml-2"
|
||||
/>
|
||||
</label>
|
||||
<multiselect
|
||||
v-model="parentContact"
|
||||
:options="searchResults"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:internal-search="false"
|
||||
:clear-on-select="false"
|
||||
:show-labels="false"
|
||||
:placeholder="$t('MERGE_CONTACTS.PARENT.PLACEHOLDER')"
|
||||
allow-empty
|
||||
:loading="isSearching"
|
||||
:max-height="150"
|
||||
open-direction="top"
|
||||
@search-change="searchChange"
|
||||
>
|
||||
<template #singleLabel="props">
|
||||
<ContactDropdownItem
|
||||
:thumbnail="props.option.thumbnail"
|
||||
:identifier="props.option.id"
|
||||
:name="props.option.name"
|
||||
:email="props.option.email"
|
||||
:phone-number="props.option.phone_number"
|
||||
/>
|
||||
</template>
|
||||
<template #option="props">
|
||||
<ContactDropdownItem
|
||||
:thumbnail="props.option.thumbnail"
|
||||
:identifier="props.option.id"
|
||||
:name="props.option.name"
|
||||
:email="props.option.email"
|
||||
:phone-number="props.option.phone_number"
|
||||
/>
|
||||
</template>
|
||||
<template #noResult>
|
||||
<span>
|
||||
{{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }}
|
||||
</span>
|
||||
</template>
|
||||
</multiselect>
|
||||
<span v-if="v$.parentContact.$error" class="message">
|
||||
{{ $t('MERGE_CONTACTS.FORM.CHILD_CONTACT.ERROR') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex multiselect-wrap--medium">
|
||||
<div
|
||||
class="w-8 relative text-base text-n-strong after:content-[''] after:h-12 after:w-0 ltr:after:left-4 rtl:after:right-4 after:absolute after:border-l after:border-solid after:border-n-strong before:content-[''] before:h-0 before:w-4 ltr:before:left-4 rtl:before:right-4 before:top-12 before:absolute before:border-b before:border-solid before:border-n-strong"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="arrow-up"
|
||||
class="absolute -top-1 ltr:left-2 rtl:right-2"
|
||||
size="17"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col w-full ltr:pl-8 rtl:pr-8">
|
||||
<label class="multiselect__label">
|
||||
{{ $t('MERGE_CONTACTS.PRIMARY.TITLE') }}
|
||||
<woot-label
|
||||
:title="$t('MERGE_CONTACTS.PRIMARY.HELP_LABEL')"
|
||||
color-scheme="alert"
|
||||
small
|
||||
class="ml-2"
|
||||
/>
|
||||
</label>
|
||||
<multiselect
|
||||
:model-value="primaryContact"
|
||||
disabled
|
||||
:options="[]"
|
||||
:show-labels="false"
|
||||
label="name"
|
||||
track-by="id"
|
||||
>
|
||||
<template #singleLabel="props">
|
||||
<ContactDropdownItem
|
||||
:thumbnail="props.option.thumbnail"
|
||||
:name="props.option.name"
|
||||
:identifier="props.option.id"
|
||||
:email="props.option.email"
|
||||
:phone-number="props.option.phoneNumber"
|
||||
/>
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContactMergeForm
|
||||
:selected-contact="primaryContact"
|
||||
:primary-contact-id="parentContactId"
|
||||
:primary-contact-list="primaryContactList"
|
||||
:is-searching="isSearching"
|
||||
:has-error="hasValidationError"
|
||||
:error-message="validationErrorMessage"
|
||||
@update:primary-contact-id="parentContactId = $event"
|
||||
@search="onSearch"
|
||||
/>
|
||||
<MergeContactSummary
|
||||
:primary-contact-name="primaryContact.name"
|
||||
:parent-contact-name="parentContactName"
|
||||
@@ -189,32 +120,3 @@ export default {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* TDOD: Clean errors in forms style */
|
||||
.error .message {
|
||||
@apply mt-0;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.multiselect {
|
||||
@apply rounded-md;
|
||||
}
|
||||
|
||||
.multiselect--disabled {
|
||||
@apply border-0;
|
||||
|
||||
.multiselect__tags {
|
||||
@apply border;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__tags {
|
||||
@apply h-auto;
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
@apply mt-px mr-1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,6 @@ import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
|
||||
import ContactConversations from './ContactConversations.vue';
|
||||
import ConversationAction from './ConversationAction.vue';
|
||||
import ConversationParticipant from './ConversationParticipant.vue';
|
||||
import ConversationSummary from './ConversationSummary.vue';
|
||||
import ContactInfo from './contact/ContactInfo.vue';
|
||||
import ContactNotes from './contact/ContactNotes.vue';
|
||||
import ConversationInfo from './ConversationInfo.vue';
|
||||
@@ -45,7 +44,6 @@ const {
|
||||
|
||||
const dragging = ref(false);
|
||||
const conversationSidebarItems = ref([]);
|
||||
const summaryRef = ref(null);
|
||||
|
||||
const shopifyIntegration = useFunctionGetter(
|
||||
'integrations/getIntegration',
|
||||
@@ -62,10 +60,6 @@ const isLinearFeatureEnabled = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
|
||||
);
|
||||
|
||||
const isCaptainTasksEnabled = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS)
|
||||
);
|
||||
|
||||
const linearIntegration = useFunctionGetter(
|
||||
'integrations/getIntegration',
|
||||
'linear'
|
||||
@@ -156,31 +150,7 @@ onMounted(() => {
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div
|
||||
v-if="
|
||||
element.name === 'conversation_summary' && isCaptainTasksEnabled
|
||||
"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_SUMMARY')"
|
||||
:is-open="isContactSidebarItemOpen('is_conv_summary_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => {
|
||||
toggleSidebarUIState('is_conv_summary_open', value);
|
||||
if (value && summaryRef) {
|
||||
summaryRef.fetchSummary();
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<ConversationSummary
|
||||
ref="summaryRef"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="element.name === 'conversation_actions'"
|
||||
v-if="element.name === 'conversation_actions'"
|
||||
class="conversation--actions"
|
||||
>
|
||||
<AccordionItem
|
||||
|
||||
@@ -85,7 +85,10 @@ export default {
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : null;
|
||||
this.$store.dispatch('setCurrentChatAssignee', agent);
|
||||
this.$store.dispatch('setCurrentChatAssignee', {
|
||||
conversationId: this.currentChat.id,
|
||||
assignee: agent,
|
||||
});
|
||||
this.$store
|
||||
.dispatch('assignAgent', {
|
||||
conversationId: this.currentChat.id,
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const isLoading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const captainTasksEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
|
||||
});
|
||||
|
||||
const cachedSummary = computed(() => currentChat.value?.cached_summary || '');
|
||||
const cachedSummaryAt = computed(
|
||||
() => currentChat.value?.cached_summary_at || 0
|
||||
);
|
||||
const lastActivityAt = computed(() => currentChat.value?.last_activity_at || 0);
|
||||
const uiFrom = 'conversation_sidebar';
|
||||
|
||||
const isStale = computed(() => {
|
||||
if (!cachedSummaryAt.value) return true;
|
||||
return lastActivityAt.value > cachedSummaryAt.value;
|
||||
});
|
||||
|
||||
const hasSummary = computed(() => !!cachedSummary.value);
|
||||
|
||||
const formattedSummary = computed(() => {
|
||||
return cachedSummary.value ? formatMessage(cachedSummary.value) : '';
|
||||
});
|
||||
|
||||
const fetchSummary = async (forceRegenerate = false) => {
|
||||
if (!captainTasksEnabled.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const result = await TasksAPI.summarize(props.conversationId, {
|
||||
forceRegenerate,
|
||||
});
|
||||
const {
|
||||
data: { message: generatedSummary },
|
||||
} = result;
|
||||
|
||||
if (generatedSummary) {
|
||||
store.dispatch('updateConversationCachedSummary', {
|
||||
conversationId: currentChat.value.id,
|
||||
cachedSummary: generatedSummary,
|
||||
cachedSummaryAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError' && e.name !== 'CanceledError') {
|
||||
error.value =
|
||||
e.response?.data?.error || t('CONVERSATION_SIDEBAR.SUMMARY.ERROR');
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const trackSummary = action => {
|
||||
useTrack(CAPTAIN_EVENTS.SUMMARIZE_USED, {
|
||||
conversationId: props.conversationId,
|
||||
uiFrom,
|
||||
action,
|
||||
});
|
||||
};
|
||||
|
||||
const generateSummary = () => {
|
||||
trackSummary('generate');
|
||||
return fetchSummary(false);
|
||||
};
|
||||
|
||||
const regenerate = () => {
|
||||
trackSummary('regenerate');
|
||||
return fetchSummary(true);
|
||||
};
|
||||
|
||||
const retryGenerate = () => {
|
||||
trackSummary('retry');
|
||||
return fetchSummary(true);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
error.value = '';
|
||||
}
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
fetchSummary,
|
||||
captainTasksEnabled,
|
||||
cachedSummary,
|
||||
isStale,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="captainTasksEnabled" class="p-3">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-4">
|
||||
<Spinner :size="20" class="text-n-slate-10" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="text-sm text-n-ruby-11">
|
||||
{{ error }}
|
||||
<Button
|
||||
:label="t('CONVERSATION_SIDEBAR.SUMMARY.RETRY')"
|
||||
size="sm"
|
||||
variant="link"
|
||||
class="ml-2"
|
||||
@click="retryGenerate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!hasSummary" class="flex flex-col items-center gap-3 py-2">
|
||||
<p class="text-sm text-n-slate-11 text-center mb-0">
|
||||
{{ t('CONVERSATION_SIDEBAR.SUMMARY.DESCRIPTION') }}
|
||||
</p>
|
||||
<Button
|
||||
:label="t('CONVERSATION_SIDEBAR.SUMMARY.GENERATE')"
|
||||
icon="i-material-symbols-auto-awesome"
|
||||
size="sm"
|
||||
@click="generateSummary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="isStale"
|
||||
class="flex items-center gap-2 mb-2 text-xs text-n-amber-11"
|
||||
>
|
||||
<span>{{ t('CONVERSATION_SIDEBAR.SUMMARY.STALE') }}</span>
|
||||
<Button
|
||||
:label="t('CONVERSATION_SIDEBAR.SUMMARY.REFRESH')"
|
||||
size="xs"
|
||||
variant="link"
|
||||
@click="regenerate"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="summary-content text-sm text-n-slate-11 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-1 [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_strong]:text-n-slate-12"
|
||||
v-html="formattedSummary"
|
||||
/>
|
||||
<div class="mt-3 pt-3 border-t border-n-weak">
|
||||
<Button
|
||||
:label="t('CONVERSATION_SIDEBAR.SUMMARY.REGENERATE')"
|
||||
icon="i-lucide-refresh-cw"
|
||||
size="sm"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
@click="regenerate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,11 +11,13 @@ import { isPhoneNumberValid } from 'shared/helpers/Validators';
|
||||
import parsePhoneNumber from 'libphonenumber-js';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
Avatar,
|
||||
ComboBox,
|
||||
},
|
||||
props: {
|
||||
contact: {
|
||||
@@ -133,6 +135,12 @@ export default {
|
||||
if (!name && !id) return '';
|
||||
return `${name} (${id})`;
|
||||
},
|
||||
onCountryChange(value) {
|
||||
const selected = this.countries.find(c => c.id === value);
|
||||
this.country = selected
|
||||
? { id: selected.id, name: selected.name }
|
||||
: { id: '', name: '' };
|
||||
},
|
||||
setDialCode() {
|
||||
if (
|
||||
this.phoneNumber !== '' &&
|
||||
@@ -363,26 +371,23 @@ export default {
|
||||
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<label>
|
||||
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
|
||||
</label>
|
||||
<multiselect
|
||||
v-model="country"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
|
||||
selected-label
|
||||
:select-label="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
|
||||
:deselect-label="$t('CONTACT_FORM.FORM.COUNTRY.REMOVE')"
|
||||
:custom-label="countryNameWithCode"
|
||||
:max-height="160"
|
||||
:options="countries"
|
||||
allow-empty
|
||||
:option-height="104"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full mb-4">
|
||||
<label>
|
||||
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
:model-value="country.id"
|
||||
:options="
|
||||
countries.map(c => ({
|
||||
value: c.id,
|
||||
label: countryNameWithCode(c),
|
||||
}))
|
||||
"
|
||||
class="[&>div>button]:!bg-n-alpha-black2"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
|
||||
:search-placeholder="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
|
||||
@update:model-value="onCountryChange"
|
||||
/>
|
||||
</div>
|
||||
<woot-input
|
||||
v-model="city"
|
||||
@@ -426,11 +431,3 @@ export default {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
::v-deep {
|
||||
.multiselect .multiselect__tags .multiselect__single {
|
||||
@apply pl-0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showEditModal: false,
|
||||
showMergeModal: false,
|
||||
showDeleteModal: false,
|
||||
};
|
||||
},
|
||||
@@ -167,11 +166,8 @@ export default {
|
||||
);
|
||||
}
|
||||
},
|
||||
closeMergeModal() {
|
||||
this.showMergeModal = false;
|
||||
},
|
||||
openMergeModal() {
|
||||
this.showMergeModal = true;
|
||||
this.$refs.mergeModal?.open();
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -324,12 +320,7 @@ export default {
|
||||
:contact="contact"
|
||||
@cancel="toggleEditModal"
|
||||
/>
|
||||
<ContactMergeModal
|
||||
v-if="showMergeModal"
|
||||
:primary-contact="contact"
|
||||
:show="showMergeModal"
|
||||
@close="closeMergeModal"
|
||||
/>
|
||||
<ContactMergeModal ref="mergeModal" :primary-contact="contact" />
|
||||
</div>
|
||||
<woot-delete-modal
|
||||
v-if="showDeleteModal"
|
||||
|
||||
@@ -1,54 +1,81 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
const agentAssignments = computed(() => [
|
||||
{
|
||||
key: 'agent_assignment_policy_index',
|
||||
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
|
||||
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-circle-fading-arrow-up',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-inbox',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'agent_capacity_policy_index',
|
||||
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
|
||||
description: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'),
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-glass-water',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-circle-minus',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-users-round',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const accountId = computed(() => Number(route.params.accountId));
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const agentAssignments = computed(() => {
|
||||
const assignments = [
|
||||
{
|
||||
key: 'agent_assignment_policy_index',
|
||||
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
|
||||
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-circle-fading-arrow-up',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-inbox',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Only show Agent Capacity if BOTH assignment_v2 AND advanced_assignment are enabled
|
||||
// advanced_assignment identifies premium users
|
||||
const hasAssignmentV2 = isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
'assignment_v2'
|
||||
);
|
||||
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
'advanced_assignment'
|
||||
);
|
||||
|
||||
if (hasAssignmentV2 && hasAdvancedAssignment) {
|
||||
assignments.push({
|
||||
key: 'agent_capacity_policy_index',
|
||||
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
|
||||
description: t(
|
||||
'ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'
|
||||
),
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-glass-water',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-circle-minus',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-users-round',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return assignments;
|
||||
});
|
||||
|
||||
const handleClick = key => {
|
||||
router.push({ name: key });
|
||||
|
||||
+3
-3
@@ -62,7 +62,7 @@ export default {
|
||||
name: 'agent_capacity_policy_index',
|
||||
component: AgentCapacityIndex,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
||||
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
name: 'agent_capacity_policy_create',
|
||||
component: AgentCapacityCreate,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
||||
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
@@ -80,7 +80,7 @@ export default {
|
||||
name: 'agent_capacity_policy_edit',
|
||||
component: AgentCapacityEdit,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
||||
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
|
||||
+46
-13
@@ -2,13 +2,14 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
|
||||
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
@@ -16,20 +17,50 @@ const { t } = useI18n();
|
||||
const formRef = ref(null);
|
||||
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
|
||||
routeName: 'agent_assignment_policy_index',
|
||||
},
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
|
||||
},
|
||||
]);
|
||||
const inboxIdFromQuery = computed(() => {
|
||||
const id = route.query.inboxId;
|
||||
return id ? Number(id) : null;
|
||||
});
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (inboxIdFromQuery.value) {
|
||||
return [
|
||||
{
|
||||
label: t('INBOX_MGMT.SETTINGS'),
|
||||
routeName: 'settings_inbox_show',
|
||||
params: { inboxId: inboxIdFromQuery.value },
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
|
||||
routeName: 'agent_assignment_policy_index',
|
||||
},
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const handleBreadcrumbClick = item => {
|
||||
router.push({
|
||||
name: item.routeName,
|
||||
});
|
||||
if (item.params) {
|
||||
const accountId = route.params.accountId;
|
||||
const inboxId = item.params.inboxId;
|
||||
// Navigate using explicit path to ensure tab parameter is included
|
||||
router.push(
|
||||
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
|
||||
);
|
||||
} else {
|
||||
router.push({
|
||||
name: item.routeName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async formState => {
|
||||
@@ -45,6 +76,8 @@ const handleSubmit = async formState => {
|
||||
params: {
|
||||
id: policy.id,
|
||||
},
|
||||
// Pass inboxId to edit page to show link prompt
|
||||
query: inboxIdFromQuery.value ? { inboxId: inboxIdFromQuery.value } : {},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
|
||||
+110
-17
@@ -14,6 +14,7 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
|
||||
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
|
||||
import ConfirmInboxDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue';
|
||||
import InboxLinkDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/InboxLinkDialog.vue';
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
|
||||
|
||||
@@ -36,13 +37,46 @@ const confirmInboxDialogRef = ref(null);
|
||||
// Store the policy linked to the inbox when adding a new inbox
|
||||
const inboxLinkedPolicy = ref(null);
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{
|
||||
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
|
||||
routeName: 'agent_assignment_policy_index',
|
||||
},
|
||||
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
||||
]);
|
||||
// Inbox linking prompt from create flow
|
||||
const inboxIdFromQuery = computed(() => {
|
||||
const id = route.query.inboxId;
|
||||
return id ? Number(id) : null;
|
||||
});
|
||||
|
||||
const suggestedInbox = computed(() => {
|
||||
if (!inboxIdFromQuery.value || !inboxes.value) return null;
|
||||
return inboxes.value.find(inbox => inbox.id === inboxIdFromQuery.value);
|
||||
});
|
||||
|
||||
const isLinkingInbox = ref(false);
|
||||
|
||||
const dismissInboxLinkPrompt = () => {
|
||||
router.replace({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: {},
|
||||
});
|
||||
};
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (inboxIdFromQuery.value) {
|
||||
return [
|
||||
{
|
||||
label: t('INBOX_MGMT.SETTINGS'),
|
||||
routeName: 'settings_inbox_show',
|
||||
params: { inboxId: inboxIdFromQuery.value },
|
||||
},
|
||||
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
|
||||
routeName: 'agent_assignment_policy_index',
|
||||
},
|
||||
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
||||
];
|
||||
});
|
||||
|
||||
const buildInboxList = allInboxes =>
|
||||
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
||||
@@ -66,22 +100,48 @@ const inboxList = computed(() =>
|
||||
const formData = computed(() => ({
|
||||
name: selectedPolicy.value?.name || '',
|
||||
description: selectedPolicy.value?.description || '',
|
||||
enabled: selectedPolicy.value?.enabled || false,
|
||||
enabled: true,
|
||||
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
|
||||
conversationPriority:
|
||||
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
|
||||
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 10,
|
||||
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 60,
|
||||
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
|
||||
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
|
||||
}));
|
||||
|
||||
const handleDeleteInbox = inboxId =>
|
||||
store.dispatch('assignmentPolicies/removeInboxPolicy', {
|
||||
policyId: selectedPolicy.value?.id,
|
||||
inboxId,
|
||||
});
|
||||
const handleDeleteInbox = async inboxId => {
|
||||
try {
|
||||
await store.dispatch('assignmentPolicies/removeInboxPolicy', {
|
||||
policyId: selectedPolicy.value?.id,
|
||||
inboxId,
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBreadcrumbClick = ({ routeName }) =>
|
||||
router.push({ name: routeName });
|
||||
const handleBreadcrumbClick = ({ routeName, params }) => {
|
||||
if (params) {
|
||||
const accountId = route.params.accountId;
|
||||
const inboxId = params.inboxId;
|
||||
// Navigate using explicit path to ensure tab parameter is included
|
||||
router.push(
|
||||
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
|
||||
);
|
||||
} else {
|
||||
router.push({ name: routeName });
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigateToInbox = inbox => {
|
||||
router.push({
|
||||
name: 'settings_inbox_show',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
inboxId: inbox.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setInboxPolicy = async (inboxId, policyId) => {
|
||||
try {
|
||||
@@ -122,6 +182,26 @@ const handleAddInbox = async inbox => {
|
||||
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id);
|
||||
};
|
||||
|
||||
const handleLinkSuggestedInbox = async () => {
|
||||
if (!suggestedInbox.value) return;
|
||||
|
||||
isLinkingInbox.value = true;
|
||||
const inbox = {
|
||||
id: suggestedInbox.value.id,
|
||||
name: suggestedInbox.value.name,
|
||||
};
|
||||
|
||||
await handleAddInbox(inbox);
|
||||
|
||||
// Clear the query param after linking
|
||||
router.replace({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: {},
|
||||
});
|
||||
isLinkingInbox.value = false;
|
||||
};
|
||||
|
||||
const handleConfirmAddInbox = async inboxId => {
|
||||
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
|
||||
|
||||
@@ -155,6 +235,11 @@ const handleSubmit = async formState => {
|
||||
const fetchPolicyData = async () => {
|
||||
if (!routeId.value) return;
|
||||
|
||||
// Fetch inboxes if not already loaded (needed for inbox link prompt)
|
||||
if (!inboxes.value?.length) {
|
||||
store.dispatch('inboxes/get');
|
||||
}
|
||||
|
||||
// Fetch policy if not available
|
||||
if (!selectedPolicy.value?.id)
|
||||
await store.dispatch('assignmentPolicies/show', routeId.value);
|
||||
@@ -186,6 +271,7 @@ watch(routeId, fetchPolicyData, { immediate: true });
|
||||
@submit="handleSubmit"
|
||||
@add-inbox="handleAddInbox"
|
||||
@delete-inbox="handleDeleteInbox"
|
||||
@navigate-to-inbox="handleNavigateToInbox"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -193,5 +279,12 @@ watch(routeId, fetchPolicyData, { immediate: true });
|
||||
ref="confirmInboxDialogRef"
|
||||
@add="handleConfirmAddInbox"
|
||||
/>
|
||||
|
||||
<InboxLinkDialog
|
||||
:inbox="suggestedInbox"
|
||||
:is-linking="isLinkingInbox"
|
||||
@link="handleLinkSuggestedInbox"
|
||||
@dismiss="dismissInboxLinkPrompt"
|
||||
/>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
+54
-29
@@ -92,43 +92,68 @@ const formData = computed(() => ({
|
||||
const handleBreadcrumbClick = ({ routeName }) =>
|
||||
router.push({ name: routeName });
|
||||
|
||||
const handleDeleteUser = agentId => {
|
||||
store.dispatch('agentCapacityPolicies/removeUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userId: agentId,
|
||||
});
|
||||
const handleDeleteUser = async agentId => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/removeUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userId: agentId,
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUser = agent => {
|
||||
store.dispatch('agentCapacityPolicies/addUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userData: { id: agent.id, capacity: 20 },
|
||||
});
|
||||
const handleAddUser = async agent => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/addUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userData: { id: agent.id, capacity: 20 },
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInboxLimit = limitId => {
|
||||
store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId,
|
||||
});
|
||||
const handleDeleteInboxLimit = async limitId => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId,
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddInboxLimit = limit => {
|
||||
store.dispatch('agentCapacityPolicies/createInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitData: {
|
||||
inboxId: limit.inboxId,
|
||||
conversationLimit: limit.conversationLimit,
|
||||
},
|
||||
});
|
||||
const handleAddInboxLimit = async limit => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/createInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitData: {
|
||||
inboxId: limit.inboxId,
|
||||
conversationLimit: limit.conversationLimit,
|
||||
},
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLimitChange = limit => {
|
||||
store.dispatch('agentCapacityPolicies/updateInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId: limit.id,
|
||||
limitData: { conversationLimit: limit.conversationLimit },
|
||||
});
|
||||
const handleLimitChange = async limit => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/updateInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId: limit.id,
|
||||
limitData: { conversationLimit: limit.conversationLimit },
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async formState => {
|
||||
|
||||
+42
-16
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
|
||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
||||
@@ -23,7 +24,6 @@ const props = defineProps({
|
||||
default: () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
assignmentOrder: ROUND_ROBIN,
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
@@ -61,18 +61,24 @@ const emit = defineEmits([
|
||||
'submit',
|
||||
'addInbox',
|
||||
'deleteInbox',
|
||||
'navigateToInbox',
|
||||
'validationChange',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isEnterprise } = useConfig();
|
||||
const route = useRoute();
|
||||
|
||||
const accountId = computed(() => Number(route.params.accountId));
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
|
||||
|
||||
const state = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
assignmentOrder: ROUND_ROBIN,
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
@@ -83,20 +89,42 @@ const validationState = ref({
|
||||
isValid: false,
|
||||
});
|
||||
|
||||
const createOption = (type, key, stateKey) => ({
|
||||
const createOption = (
|
||||
type,
|
||||
key,
|
||||
stateKey,
|
||||
disabled = false,
|
||||
disabledMessage = ''
|
||||
) => ({
|
||||
key,
|
||||
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
|
||||
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
|
||||
isActive: state[stateKey] === key,
|
||||
disabled,
|
||||
disabledMessage,
|
||||
});
|
||||
|
||||
const assignmentOrderOptions = computed(() => {
|
||||
const options = OPTIONS.ORDER.filter(
|
||||
key => isEnterprise || key !== 'balanced'
|
||||
);
|
||||
return options.map(key =>
|
||||
createOption('ASSIGNMENT_ORDER', key, 'assignmentOrder')
|
||||
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
'advanced_assignment'
|
||||
);
|
||||
|
||||
return OPTIONS.ORDER.map(key => {
|
||||
const isBalanced = key === 'balanced';
|
||||
const disabled = isBalanced && !hasAdvancedAssignment;
|
||||
const disabledMessage = disabled
|
||||
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_MESSAGE`)
|
||||
: '';
|
||||
|
||||
return createOption(
|
||||
'ASSIGNMENT_ORDER',
|
||||
key,
|
||||
'assignmentOrder',
|
||||
disabled,
|
||||
disabledMessage
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const assignmentPriorityOptions = computed(() =>
|
||||
@@ -131,7 +159,7 @@ const resetForm = () => {
|
||||
Object.assign(state, {
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
assignmentOrder: ROUND_ROBIN,
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
@@ -162,15 +190,10 @@ defineExpose({
|
||||
<BaseInfo
|
||||
v-model:policy-name="state.name"
|
||||
v-model:description="state.description"
|
||||
v-model:enabled="state.enabled"
|
||||
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
|
||||
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
|
||||
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
|
||||
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
|
||||
:status-label="t(`${BASE_KEY}.FORM.STATUS.LABEL`)"
|
||||
:status-placeholder="
|
||||
t(`${BASE_KEY}.FORM.STATUS.${state.enabled ? 'ACTIVE' : 'INACTIVE'}`)
|
||||
"
|
||||
@validation-change="handleValidationChange"
|
||||
/>
|
||||
|
||||
@@ -193,6 +216,8 @@ defineExpose({
|
||||
:label="option.label"
|
||||
:description="option.description"
|
||||
:is-active="option.isActive"
|
||||
:disabled="option.disabled"
|
||||
:disabled-message="option.disabledMessage"
|
||||
@select="state[section.key] = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -251,6 +276,7 @@ defineExpose({
|
||||
:is-fetching="isInboxLoading"
|
||||
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
|
||||
@delete="$emit('deleteInbox', $event)"
|
||||
@navigate="$emit('navigateToInbox', $event)"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isLinking: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['link', 'dismiss']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
if (!props.inbox) return 'i-lucide-inbox';
|
||||
return getInboxIconByType(
|
||||
props.inbox.channelType,
|
||||
props.inbox.medium,
|
||||
'line'
|
||||
);
|
||||
});
|
||||
|
||||
const openDialog = () => {
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('link');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('dismiss');
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.inbox,
|
||||
async newInbox => {
|
||||
if (newInbox) {
|
||||
await nextTick();
|
||||
openDialog();
|
||||
} else {
|
||||
closeDialog();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
defineExpose({ openDialog, closeDialog });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.TITLE'
|
||||
)
|
||||
"
|
||||
:confirm-button-label="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.LINK_BUTTON'
|
||||
)
|
||||
"
|
||||
:cancel-button-label="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.CANCEL_BUTTON'
|
||||
)
|
||||
"
|
||||
:is-loading="isLinking"
|
||||
@confirm="handleConfirm"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #description>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 rounded-xl border border-n-weak bg-n-alpha-1"
|
||||
>
|
||||
<div
|
||||
class="flex-shrink-0 size-10 rounded-lg bg-n-alpha-2 flex items-center justify-center"
|
||||
>
|
||||
<i :class="inboxIcon" class="text-lg text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-sm font-medium text-n-slate-12 truncate">
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -7,10 +7,12 @@ import { convertToAttributeSlug } from 'dashboard/helper/commons.js';
|
||||
import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
TagInput,
|
||||
},
|
||||
props: {
|
||||
onClose: {
|
||||
@@ -41,9 +43,8 @@ export default {
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
values: [],
|
||||
options: [],
|
||||
show: true,
|
||||
isTouched: false,
|
||||
tagInputTouched: false,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -63,21 +64,21 @@ export default {
|
||||
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
|
||||
}));
|
||||
},
|
||||
isMultiselectInvalid() {
|
||||
return this.isTouched && this.values.length === 0;
|
||||
},
|
||||
isTagInputInvalid() {
|
||||
isTagInputEmpty() {
|
||||
return this.isAttributeTypeList && this.values.length === 0;
|
||||
},
|
||||
isTagInputInvalid() {
|
||||
return this.tagInputTouched && this.isTagInputEmpty;
|
||||
},
|
||||
attributeListValues() {
|
||||
return this.values.map(item => item.name);
|
||||
return this.values;
|
||||
},
|
||||
isButtonDisabled() {
|
||||
return (
|
||||
this.v$.displayName.$invalid ||
|
||||
this.v$.description.$invalid ||
|
||||
this.uiFlags.isCreating ||
|
||||
this.isTagInputInvalid
|
||||
this.isTagInputEmpty
|
||||
);
|
||||
},
|
||||
keyErrorMessage() {
|
||||
@@ -119,17 +120,14 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
attributeType() {
|
||||
this.tagInputTouched = false;
|
||||
this.values = [];
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
addTagValue(tagValue) {
|
||||
const tag = {
|
||||
name: tagValue,
|
||||
};
|
||||
this.values.push(tag);
|
||||
this.$refs.tagInput.$el.focus();
|
||||
},
|
||||
onTouch() {
|
||||
this.isTouched = true;
|
||||
},
|
||||
onDisplayNameChange() {
|
||||
this.attributeKey = convertToAttributeSlug(this.displayName);
|
||||
},
|
||||
@@ -237,27 +235,25 @@ export default {
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="isAttributeTypeList" class="multiselect--wrap">
|
||||
<label>
|
||||
<div v-if="isAttributeTypeList" class="mb-4">
|
||||
<label class="mb-1 block">
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.LABEL') }}
|
||||
</label>
|
||||
<multiselect
|
||||
ref="tagInput"
|
||||
v-model="values"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
|
||||
"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:class="{ invalid: isMultiselectInvalid }"
|
||||
:options="options"
|
||||
multiple
|
||||
taggable
|
||||
@close="onTouch"
|
||||
@tag="addTagValue"
|
||||
/>
|
||||
<div
|
||||
class="rounded-xl border px-3 py-2"
|
||||
:class="isTagInputInvalid ? 'border-n-ruby-9' : 'border-n-weak'"
|
||||
>
|
||||
<TagInput
|
||||
v-model="values"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
|
||||
"
|
||||
allow-create
|
||||
@blur="tagInputTouched = true"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
v-show="isMultiselectInvalid"
|
||||
v-show="isTagInputInvalid"
|
||||
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
|
||||
@@ -312,22 +308,4 @@ export default {
|
||||
padding: 0 0.5rem 0.5rem 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.multiselect--wrap {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.multiselect {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multiselect--active .multiselect__tags {
|
||||
border-radius: 0.3125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,10 +5,12 @@ import { required, minLength } from '@vuelidate/validators';
|
||||
import { getRegexp } from 'shared/helpers/Validators';
|
||||
import { ATTRIBUTE_TYPES } from './constants';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
TagInput,
|
||||
},
|
||||
props: {
|
||||
selectedAttribute: {
|
||||
@@ -35,8 +37,7 @@ export default {
|
||||
show: true,
|
||||
attributeKey: '',
|
||||
values: [],
|
||||
options: [],
|
||||
isTouched: true,
|
||||
tagInputTouched: false,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@@ -65,20 +66,19 @@ export default {
|
||||
}));
|
||||
},
|
||||
setAttributeListValue() {
|
||||
return this.selectedAttribute.attribute_values.map(values => ({
|
||||
name: values,
|
||||
}));
|
||||
return this.selectedAttribute.attribute_values || [];
|
||||
},
|
||||
updatedAttributeListValues() {
|
||||
return this.values.map(item => item.name);
|
||||
return this.values;
|
||||
},
|
||||
isButtonDisabled() {
|
||||
return this.v$.description.$invalid || this.isMultiselectInvalid;
|
||||
return this.v$.description.$invalid || this.isTagInputEmpty;
|
||||
},
|
||||
isMultiselectInvalid() {
|
||||
return (
|
||||
this.isAttributeTypeList && this.isTouched && this.values.length === 0
|
||||
);
|
||||
isTagInputEmpty() {
|
||||
return this.isAttributeTypeList && this.values.length === 0;
|
||||
},
|
||||
isTagInputInvalid() {
|
||||
return this.tagInputTouched && this.isTagInputEmpty;
|
||||
},
|
||||
|
||||
pageTitle() {
|
||||
@@ -116,13 +116,6 @@ export default {
|
||||
onClose() {
|
||||
this.$emit('onClose');
|
||||
},
|
||||
addTagValue(tagValue) {
|
||||
const tag = {
|
||||
name: tagValue,
|
||||
};
|
||||
this.values.push(tag);
|
||||
this.$refs.tagInput.$el.focus();
|
||||
},
|
||||
setFormValues() {
|
||||
const regexPattern = this.selectedAttribute.regex_pattern
|
||||
? getRegexp(this.selectedAttribute.regex_pattern).source
|
||||
@@ -225,24 +218,25 @@ export default {
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="isAttributeTypeList" class="multiselect--wrap">
|
||||
<label>
|
||||
<div v-if="isAttributeTypeList" class="mb-4">
|
||||
<label class="mb-1 block">
|
||||
{{ $t('ATTRIBUTES_MGMT.EDIT.TYPE.LIST.LABEL') }}
|
||||
</label>
|
||||
<multiselect
|
||||
ref="tagInput"
|
||||
v-model="values"
|
||||
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:class="{ invalid: isMultiselectInvalid }"
|
||||
:options="options"
|
||||
multiple
|
||||
taggable
|
||||
@tag="addTagValue"
|
||||
/>
|
||||
<div
|
||||
class="rounded-xl border px-3 py-2"
|
||||
:class="isTagInputInvalid ? 'border-n-ruby-9' : 'border-n-weak'"
|
||||
>
|
||||
<TagInput
|
||||
v-model="values"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
|
||||
"
|
||||
allow-create
|
||||
@blur="tagInputTouched = true"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
v-show="isMultiselectInvalid"
|
||||
v-show="isTagInputInvalid"
|
||||
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
|
||||
@@ -297,22 +291,4 @@ export default {
|
||||
padding: 0 0.5rem 0.5rem 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.multiselect--wrap {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.multiselect {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multiselect--active .multiselect__tags {
|
||||
border-radius: 0.3125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+57
-324
@@ -1,21 +1,12 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAutomation } from 'dashboard/composables/useAutomation';
|
||||
import { validateAutomation } from 'dashboard/helper/validations';
|
||||
import {
|
||||
generateAutomationPayload,
|
||||
getAttributes,
|
||||
getInputType,
|
||||
getOperators,
|
||||
getCustomAttributeType,
|
||||
showActionInput,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
|
||||
import AutomationRuleForm from './AutomationRuleForm.vue';
|
||||
|
||||
const start_value = {
|
||||
const emit = defineEmits(['saveAutomation']);
|
||||
|
||||
const START_VALUE = {
|
||||
name: null,
|
||||
description: null,
|
||||
event_name: 'conversation_created',
|
||||
@@ -36,318 +27,60 @@ const start_value = {
|
||||
],
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterInputBox,
|
||||
AutomationActionInput,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['saveAutomation'],
|
||||
setup() {
|
||||
const {
|
||||
automation,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetFilter,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
} = useAutomation(start_value);
|
||||
return {
|
||||
automation,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetFilter,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key,
|
||||
automationMutated: false,
|
||||
show: true,
|
||||
showDeleteConfirmationModal: false,
|
||||
allCustomAttributes: [],
|
||||
mode: 'create',
|
||||
errors: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
automationRuleEvents() {
|
||||
return AUTOMATION_RULE_EVENTS.map(event => ({
|
||||
...event,
|
||||
value: this.$t(`AUTOMATION.EVENTS.${event.value}`),
|
||||
}));
|
||||
},
|
||||
hasAutomationMutated() {
|
||||
if (
|
||||
this.automation.conditions[0].values ||
|
||||
this.automation.actions[0].action_params.length
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
automationActionTypes() {
|
||||
const actionTypes = this.isFeatureEnabled('sla')
|
||||
? AUTOMATION_ACTION_TYPES
|
||||
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
|
||||
const store = useStore();
|
||||
const formRef = ref(null);
|
||||
|
||||
return actionTypes.map(action => ({
|
||||
...action,
|
||||
label: this.$t(`AUTOMATION.ACTIONS.${action.label}`),
|
||||
}));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxes/get');
|
||||
this.$store.dispatch('agents/get');
|
||||
this.$store.dispatch('contacts/get');
|
||||
this.$store.dispatch('teams/get');
|
||||
this.$store.dispatch('labels/get');
|
||||
this.$store.dispatch('campaigns/get');
|
||||
this.allCustomAttributes = this.$store.getters['attributes/getAttributes'];
|
||||
this.manifestCustomAttributes();
|
||||
},
|
||||
methods: {
|
||||
getAttributes,
|
||||
getInputType,
|
||||
getOperators,
|
||||
getCustomAttributeType,
|
||||
showActionInput,
|
||||
isFeatureEnabled(flag) {
|
||||
return this.isFeatureEnabledonAccount(this.accountId, flag);
|
||||
},
|
||||
emitSaveAutomation() {
|
||||
this.errors = validateAutomation(this.automation);
|
||||
if (Object.keys(this.errors).length === 0) {
|
||||
const automation = generateAutomationPayload(this.automation);
|
||||
this.$emit('saveAutomation', automation, this.mode);
|
||||
}
|
||||
},
|
||||
getTranslatedAttributes(type, event) {
|
||||
return getAttributes(type, event).map(attribute => {
|
||||
// Skip translation
|
||||
// 1. If customAttributeType key is present then its rendering attributes from API
|
||||
// 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title
|
||||
const skipTranslation =
|
||||
attribute.customAttributeType ||
|
||||
[
|
||||
'contact_custom_attribute',
|
||||
'conversation_custom_attribute',
|
||||
].includes(attribute.key);
|
||||
const {
|
||||
automation,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
} = useAutomation(START_VALUE);
|
||||
|
||||
return {
|
||||
...attribute,
|
||||
name: skipTranslation
|
||||
? attribute.name
|
||||
: this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
const open = () => {
|
||||
automation.value = structuredClone(START_VALUE);
|
||||
manifestCustomAttributes();
|
||||
formRef.value?.open();
|
||||
};
|
||||
const close = () => formRef.value?.close();
|
||||
|
||||
const onSave = (payload, mode) => {
|
||||
emit('saveAutomation', payload, mode);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
store.dispatch('agents/get');
|
||||
store.dispatch('contacts/get');
|
||||
store.dispatch('teams/get');
|
||||
store.dispatch('labels/get');
|
||||
store.dispatch('campaigns/get');
|
||||
});
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<woot-modal-header :header-title="$t('AUTOMATION.ADD.TITLE')" />
|
||||
<div class="flex flex-col modal-content">
|
||||
<div class="w-full">
|
||||
<woot-input
|
||||
v-model="automation.name"
|
||||
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: errors.name }"
|
||||
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="automation.description"
|
||||
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: errors.description }"
|
||||
:error="
|
||||
errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''
|
||||
"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="mb-6">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
class="m-0"
|
||||
@change="onEventChange(automation)"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="hasAutomationMutated"
|
||||
class="text-xs text-right text-n-teal-10 pt-1"
|
||||
>
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- // Conditions Start -->
|
||||
<section>
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
|
||||
>
|
||||
<FilterInputBox
|
||||
v-for="(condition, i) in automation.conditions"
|
||||
:key="i"
|
||||
v-model="automation.conditions[i]"
|
||||
:filter-attributes="
|
||||
getTranslatedAttributes(automationTypes, automation.event_name)
|
||||
"
|
||||
:input-type="
|
||||
getInputType(
|
||||
allCustomAttributes,
|
||||
automationTypes,
|
||||
automation,
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:operators="
|
||||
getOperators(
|
||||
allCustomAttributes,
|
||||
automationTypes,
|
||||
automation,
|
||||
mode,
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:dropdown-values="
|
||||
getConditionDropdownValues(
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:show-query-operator="i !== automation.conditions.length - 1"
|
||||
:custom-attribute-type="
|
||||
getCustomAttributeType(
|
||||
automationTypes,
|
||||
automation,
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:error-message="
|
||||
errors[`condition_${i}`]
|
||||
? $t(`AUTOMATION.ERRORS.${errors[`condition_${i}`]}`)
|
||||
: ''
|
||||
"
|
||||
@reset-filter="resetFilter(i, automation.conditions[i])"
|
||||
@remove-filter="removeFilter(i)"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- // Conditions End -->
|
||||
<!-- // Actions Start -->
|
||||
<section>
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
|
||||
>
|
||||
<AutomationActionInput
|
||||
v-for="(action, i) in automation.actions"
|
||||
:key="i"
|
||||
v-model="automation.actions[i]"
|
||||
:action-types="automationActionTypes"
|
||||
:dropdown-values="
|
||||
getActionDropdownValues(automation.actions[i].action_name)
|
||||
"
|
||||
:show-action-input="
|
||||
showActionInput(
|
||||
automationActionTypes,
|
||||
automation.actions[i].action_name
|
||||
)
|
||||
"
|
||||
:error-message="
|
||||
errors[`action_${i}`]
|
||||
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
|
||||
: ''
|
||||
"
|
||||
@reset-action="resetAction(i)"
|
||||
@remove-action="removeAction(i)"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
|
||||
@click="appendNewAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- // Actions End -->
|
||||
<div class="w-full">
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('AUTOMATION.ADD.CANCEL_BUTTON_TEXT')"
|
||||
@click.prevent="onClose"
|
||||
/>
|
||||
<NextButton
|
||||
solid
|
||||
blue
|
||||
type="submit"
|
||||
:label="$t('AUTOMATION.ADD.SUBMIT')"
|
||||
@click="emitSaveAutomation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AutomationRuleForm
|
||||
ref="formRef"
|
||||
v-model:automation="automation"
|
||||
mode="create"
|
||||
:automation-types="automationTypes"
|
||||
:get-condition-dropdown-values="getConditionDropdownValues"
|
||||
:get-action-dropdown-values="getActionDropdownValues"
|
||||
:append-new-condition="appendNewCondition"
|
||||
:append-new-action="appendNewAction"
|
||||
:remove-filter="removeFilter"
|
||||
:remove-action="removeAction"
|
||||
:reset-action="resetAction"
|
||||
:on-event-change="onEventChange"
|
||||
@save="onSave"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
<script setup>
|
||||
import { ref, computed, h, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useOperators } from 'dashboard/components-next/filter/operators';
|
||||
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import {
|
||||
generateAutomationPayload,
|
||||
getAttributes,
|
||||
getFileName,
|
||||
showActionInput,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import { validateAutomation } from 'dashboard/helper/validations';
|
||||
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['create', 'edit'].includes(value),
|
||||
},
|
||||
automationTypes: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
getConditionDropdownValues: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
getActionDropdownValues: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
appendNewCondition: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
appendNewAction: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
removeFilter: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
removeAction: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
resetAction: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onEventChange: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save']);
|
||||
const automation = defineModel('automation', { type: Object, default: null });
|
||||
|
||||
const INPUT_TYPE_MAP = {
|
||||
multi_select: 'multiSelect',
|
||||
search_select: 'searchSelect',
|
||||
plain_text: 'plainText',
|
||||
comma_separated_plain_text: 'plainText',
|
||||
date: 'date',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { operators } = useOperators();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const conditionsRef = useTemplateRef('conditionsRef');
|
||||
const errors = ref({});
|
||||
|
||||
const isEditMode = computed(() => props.mode === 'edit');
|
||||
|
||||
const titleKey = computed(() =>
|
||||
isEditMode.value ? 'AUTOMATION.EDIT.TITLE' : 'AUTOMATION.ADD.TITLE'
|
||||
);
|
||||
const cancelKey = computed(() =>
|
||||
isEditMode.value
|
||||
? 'AUTOMATION.EDIT.CANCEL_BUTTON_TEXT'
|
||||
: 'AUTOMATION.ADD.CANCEL_BUTTON_TEXT'
|
||||
);
|
||||
const submitKey = computed(() =>
|
||||
isEditMode.value ? 'AUTOMATION.EDIT.SUBMIT' : 'AUTOMATION.ADD.SUBMIT'
|
||||
);
|
||||
|
||||
const getTranslatedAttributes = (type, event) => {
|
||||
return getAttributes(type, event).map(attribute => {
|
||||
const skipTranslation =
|
||||
attribute.customAttributeType ||
|
||||
['contact_custom_attribute', 'conversation_custom_attribute'].includes(
|
||||
attribute.key
|
||||
);
|
||||
return {
|
||||
...attribute,
|
||||
name: skipTranslation
|
||||
? attribute.name
|
||||
: t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const eventName = computed(() => automation.value?.event_name);
|
||||
|
||||
const filterTypes = computed(() => {
|
||||
const event = eventName.value;
|
||||
if (!event || !props.automationTypes[event]) return [];
|
||||
|
||||
const attributes = getTranslatedAttributes(props.automationTypes, event);
|
||||
|
||||
return attributes.map(attr => {
|
||||
if (attr.disabled) {
|
||||
return { value: attr.key, label: attr.name, disabled: true };
|
||||
}
|
||||
|
||||
const mappedInputType = INPUT_TYPE_MAP[attr.inputType] || 'plainText';
|
||||
const options = props.getConditionDropdownValues(attr.key) || [];
|
||||
|
||||
const filterOperators = (attr.filterOperators || []).map(op => {
|
||||
const enriched = operators.value[op.value];
|
||||
if (enriched) return enriched;
|
||||
return {
|
||||
value: op.value,
|
||||
label: t(`FILTER.OPERATOR_LABELS.${op.value}`),
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: h('span', { class: 'i-ph-equals-bold !text-n-blue-11' }),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
attributeKey: attr.key,
|
||||
value: attr.key,
|
||||
attributeName: attr.name,
|
||||
label: attr.name,
|
||||
inputType: mappedInputType,
|
||||
options,
|
||||
filterOperators,
|
||||
dataType: 'text',
|
||||
attributeModel: attr.customAttributeType || 'standard',
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const automationRuleEvents = computed(() =>
|
||||
AUTOMATION_RULE_EVENTS.map(event => ({
|
||||
...event,
|
||||
value: t(`AUTOMATION.EVENTS.${event.value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const hasAutomationMutated = computed(() => {
|
||||
return Boolean(
|
||||
automation.value?.conditions[0]?.values ||
|
||||
automation.value?.actions[0]?.action_params?.length
|
||||
);
|
||||
});
|
||||
|
||||
const automationActionTypes = computed(() => {
|
||||
const actionTypes = isCloudFeatureEnabled('sla')
|
||||
? AUTOMATION_ACTION_TYPES
|
||||
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
|
||||
|
||||
return actionTypes.map(action => ({
|
||||
...action,
|
||||
label: t(`AUTOMATION.ACTIONS.${action.label}`),
|
||||
}));
|
||||
});
|
||||
|
||||
const hasConditionErrors = computed(() =>
|
||||
Object.keys(errors.value).some(key => key.startsWith('condition_'))
|
||||
);
|
||||
|
||||
const hasActionErrors = computed(() =>
|
||||
Object.keys(errors.value).some(key => key.startsWith('action_'))
|
||||
);
|
||||
|
||||
watch(
|
||||
() => automation.value,
|
||||
() => {
|
||||
if (Object.keys(errors.value).length) {
|
||||
errors.value = {};
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const isConditionsValid = () => {
|
||||
if (!conditionsRef.value) return true;
|
||||
return conditionsRef.value.every(condition => condition.validate());
|
||||
};
|
||||
|
||||
const resetValidation = () => {
|
||||
errors.value = {};
|
||||
conditionsRef.value?.forEach(c => c.resetValidation());
|
||||
};
|
||||
|
||||
const syncCustomAttributeTypes = () => {
|
||||
automation.value.conditions.forEach(condition => {
|
||||
const filterType = filterTypes.value.find(
|
||||
ft => ft.attributeKey === condition.attribute_key
|
||||
);
|
||||
condition.custom_attribute_type =
|
||||
filterType?.attributeModel === 'standard'
|
||||
? ''
|
||||
: filterType?.attributeModel || '';
|
||||
});
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
resetValidation();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
resetValidation();
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const emitSaveAutomation = () => {
|
||||
syncCustomAttributeTypes();
|
||||
const conditionsValid = isConditionsValid();
|
||||
errors.value = validateAutomation(automation.value);
|
||||
if (Object.keys(errors.value).length === 0 && conditionsValid) {
|
||||
const payload = generateAutomationPayload(automation.value);
|
||||
emit('save', payload, props.mode);
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
position="top"
|
||||
:title="$t(titleKey)"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
overflow-y-auto
|
||||
>
|
||||
<div v-if="automation" class="flex flex-col w-full">
|
||||
<woot-input
|
||||
v-model="automation.name"
|
||||
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: errors.name }"
|
||||
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="automation.description"
|
||||
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: errors.description }"
|
||||
:error="errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="mb-6">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
class="m-0"
|
||||
@change="onEventChange()"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="!isEditMode && hasAutomationMutated"
|
||||
class="text-xs text-right text-n-teal-10 pt-1"
|
||||
>
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Conditions Start -->
|
||||
<section class="mb-5">
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<ul
|
||||
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
|
||||
:class="
|
||||
hasConditionErrors
|
||||
? 'outline-n-ruby-5 bg-n-ruby-2/50'
|
||||
: 'outline-n-weak dark:outline-n-strong'
|
||||
"
|
||||
>
|
||||
<template v-for="(condition, i) in automation.conditions" :key="i">
|
||||
<ConditionRow
|
||||
v-if="i === 0"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="automation.conditions[i].filter_operator"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="automation.conditions[i].filter_operator"
|
||||
v-model:query-operator="
|
||||
automation.conditions[i - 1].query_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
show-query-operator
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- Conditions End -->
|
||||
<!-- Actions Start -->
|
||||
<section>
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
|
||||
</label>
|
||||
<ul
|
||||
class="grid list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1 border-solid"
|
||||
:class="
|
||||
hasActionErrors
|
||||
? 'outline-n-ruby-5 bg-n-ruby-2/50'
|
||||
: 'outline-n-weak dark:outline-n-strong'
|
||||
"
|
||||
>
|
||||
<AutomationActionInput
|
||||
v-for="(action, i) in automation.actions"
|
||||
:key="i"
|
||||
v-model="automation.actions[i]"
|
||||
:action-types="automationActionTypes"
|
||||
dropdown-max-height="max-h-[7.5rem]"
|
||||
:dropdown-values="getActionDropdownValues(action.action_name)"
|
||||
:show-action-input="
|
||||
showActionInput(automationActionTypes, action.action_name)
|
||||
"
|
||||
:error-message="
|
||||
errors[`action_${i}`]
|
||||
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
|
||||
: ''
|
||||
"
|
||||
:initial-file-name="
|
||||
isEditMode ? getFileName(action, automation.files) : ''
|
||||
"
|
||||
@reset-action="resetAction(i)"
|
||||
@remove-action="removeAction(i)"
|
||||
/>
|
||||
<div class="pt-2">
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
|
||||
@click="appendNewAction"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- Actions End -->
|
||||
<div class="w-full mt-8">
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-4">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t(cancelKey)"
|
||||
@click.prevent="close"
|
||||
/>
|
||||
<NextButton
|
||||
solid
|
||||
blue
|
||||
type="submit"
|
||||
:label="$t(submitKey)"
|
||||
@click="emitSaveAutomation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
+68
-333
@@ -1,345 +1,80 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAutomation } from 'dashboard/composables/useAutomation';
|
||||
import { useEditableAutomation } from 'dashboard/composables/useEditableAutomation';
|
||||
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import {
|
||||
getFileName,
|
||||
generateAutomationPayload,
|
||||
getAttributes,
|
||||
getInputType,
|
||||
getOperators,
|
||||
getCustomAttributeType,
|
||||
showActionInput,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import { validateAutomation } from 'dashboard/helper/validations';
|
||||
import AutomationRuleForm from './AutomationRuleForm.vue';
|
||||
import { AUTOMATION_ACTION_TYPES } from './constants';
|
||||
|
||||
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
|
||||
const props = defineProps({
|
||||
selectedResponse: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterInputBox,
|
||||
NextButton,
|
||||
AutomationActionInput,
|
||||
},
|
||||
props: {
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
selectedResponse: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['saveAutomation'],
|
||||
setup() {
|
||||
const {
|
||||
automation,
|
||||
const emit = defineEmits(['saveAutomation']);
|
||||
|
||||
const allCustomAttributes = useMapGetter('attributes/getAttributes');
|
||||
const formRef = ref(null);
|
||||
|
||||
const {
|
||||
automation,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
} = useAutomation();
|
||||
|
||||
const { formatAutomation } = useEditableAutomation();
|
||||
|
||||
const open = () => formRef.value?.open();
|
||||
const close = () => formRef.value?.close();
|
||||
|
||||
const onSave = (payload, mode) => {
|
||||
emit('saveAutomation', payload, mode);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.selectedResponse,
|
||||
value => {
|
||||
if (!value?.conditions) return;
|
||||
|
||||
manifestCustomAttributes();
|
||||
|
||||
automation.value = formatAutomation(
|
||||
value,
|
||||
allCustomAttributes.value,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetFilter,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
} = useAutomation();
|
||||
const { formatAutomation } = useEditableAutomation();
|
||||
return {
|
||||
automation,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetFilter,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
formatAutomation,
|
||||
manifestCustomAttributes,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key,
|
||||
automationMutated: false,
|
||||
show: true,
|
||||
showDeleteConfirmationModal: false,
|
||||
allCustomAttributes: [],
|
||||
mode: 'edit',
|
||||
errors: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
automationRuleEvents() {
|
||||
return AUTOMATION_RULE_EVENTS.map(event => ({
|
||||
...event,
|
||||
value: this.$t(`AUTOMATION.EVENTS.${event.value}`),
|
||||
}));
|
||||
},
|
||||
hasAutomationMutated() {
|
||||
if (
|
||||
this.automation.conditions[0].values ||
|
||||
this.automation.actions[0].action_params.length
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
automationActionTypes() {
|
||||
const actionTypes = this.isFeatureEnabled('sla')
|
||||
? AUTOMATION_ACTION_TYPES
|
||||
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
|
||||
|
||||
return actionTypes.map(action => ({
|
||||
...action,
|
||||
label: this.$t(`AUTOMATION.ACTIONS.${action.label}`),
|
||||
}));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.manifestCustomAttributes();
|
||||
this.allCustomAttributes = this.$store.getters['attributes/getAttributes'];
|
||||
|
||||
this.automation = this.formatAutomation(
|
||||
this.selectedResponse,
|
||||
this.allCustomAttributes,
|
||||
this.automationTypes,
|
||||
this.automationActionTypes
|
||||
AUTOMATION_ACTION_TYPES
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
getFileName,
|
||||
getAttributes,
|
||||
getInputType,
|
||||
getOperators,
|
||||
getCustomAttributeType,
|
||||
showActionInput,
|
||||
isFeatureEnabled(flag) {
|
||||
return this.isFeatureEnabledonAccount(this.accountId, flag);
|
||||
},
|
||||
emitSaveAutomation() {
|
||||
this.errors = validateAutomation(this.automation);
|
||||
if (Object.keys(this.errors).length === 0) {
|
||||
const automation = generateAutomationPayload(this.automation);
|
||||
this.$emit('saveAutomation', automation, this.mode);
|
||||
}
|
||||
},
|
||||
getTranslatedAttributes(type, event) {
|
||||
return getAttributes(type, event).map(attribute => {
|
||||
// Skip translation
|
||||
// 1. If customAttributeType key is present then its rendering attributes from API
|
||||
// 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title
|
||||
const skipTranslation =
|
||||
attribute.customAttributeType ||
|
||||
[
|
||||
'contact_custom_attribute',
|
||||
'conversation_custom_attribute',
|
||||
].includes(attribute.key);
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
...attribute,
|
||||
name: skipTranslation
|
||||
? attribute.name
|
||||
: this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<woot-modal-header :header-title="$t('AUTOMATION.EDIT.TITLE')" />
|
||||
<div class="flex flex-col modal-content">
|
||||
<div v-if="automation" class="w-full">
|
||||
<woot-input
|
||||
v-model="automation.name"
|
||||
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: errors.name }"
|
||||
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="automation.description"
|
||||
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: errors.description }"
|
||||
:error="
|
||||
errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''
|
||||
"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="event_wrapper">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
@change="onEventChange(automation)"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- // Conditions Start -->
|
||||
<section>
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
|
||||
>
|
||||
<FilterInputBox
|
||||
v-for="(condition, i) in automation.conditions"
|
||||
:key="i"
|
||||
v-model="automation.conditions[i]"
|
||||
:filter-attributes="
|
||||
getTranslatedAttributes(automationTypes, automation.event_name)
|
||||
"
|
||||
:input-type="
|
||||
getInputType(
|
||||
allCustomAttributes,
|
||||
automationTypes,
|
||||
automation,
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:operators="
|
||||
getOperators(
|
||||
allCustomAttributes,
|
||||
automationTypes,
|
||||
automation,
|
||||
mode,
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:dropdown-values="
|
||||
getConditionDropdownValues(
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:custom-attribute-type="
|
||||
getCustomAttributeType(
|
||||
automationTypes,
|
||||
automation,
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:show-query-operator="i !== automation.conditions.length - 1"
|
||||
:error-message="
|
||||
errors[`condition_${i}`]
|
||||
? $t(`AUTOMATION.ERRORS.${errors[`condition_${i}`]}`)
|
||||
: ''
|
||||
"
|
||||
@reset-filter="resetFilter(i, automation.conditions[i])"
|
||||
@remove-filter="removeFilter(i)"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- // Conditions End -->
|
||||
<!-- // Actions Start -->
|
||||
<section>
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
|
||||
>
|
||||
<AutomationActionInput
|
||||
v-for="(action, i) in automation.actions"
|
||||
:key="i"
|
||||
v-model="automation.actions[i]"
|
||||
:action-types="automationActionTypes"
|
||||
:dropdown-values="getActionDropdownValues(action.action_name)"
|
||||
:show-action-input="
|
||||
showActionInput(automationActionTypes, action.action_name)
|
||||
"
|
||||
:error-message="
|
||||
errors[`action_${i}`]
|
||||
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
|
||||
: ''
|
||||
"
|
||||
:initial-file-name="getFileName(action, automation.files)"
|
||||
@reset-action="resetAction(i)"
|
||||
@remove-action="removeAction(i)"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
|
||||
@click="appendNewAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- // Actions End -->
|
||||
<div class="w-full">
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('AUTOMATION.EDIT.CANCEL_BUTTON_TEXT')"
|
||||
@click.prevent="onClose"
|
||||
/>
|
||||
<NextButton
|
||||
solid
|
||||
blue
|
||||
type="submit"
|
||||
:label="$t('AUTOMATION.EDIT.SUBMIT')"
|
||||
@click="emitSaveAutomation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AutomationRuleForm
|
||||
ref="formRef"
|
||||
v-model:automation="automation"
|
||||
mode="edit"
|
||||
:automation-types="automationTypes"
|
||||
:get-condition-dropdown-values="getConditionDropdownValues"
|
||||
:get-action-dropdown-values="getActionDropdownValues"
|
||||
:append-new-condition="appendNewCondition"
|
||||
:append-new-action="appendNewAction"
|
||||
:remove-filter="removeFilter"
|
||||
:remove-action="removeAction"
|
||||
:reset-action="resetAction"
|
||||
:on-event-change="onEventChange"
|
||||
@save="onSave"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.event_wrapper {
|
||||
select {
|
||||
@apply m-0;
|
||||
}
|
||||
.info-message {
|
||||
@apply text-xs text-n-teal-10 text-right;
|
||||
}
|
||||
|
||||
@apply mb-6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,8 +16,8 @@ const { t } = useI18n();
|
||||
const confirmDialog = ref(null);
|
||||
|
||||
const loading = ref({});
|
||||
const showAddPopup = ref(false);
|
||||
const showEditPopup = ref(false);
|
||||
const addDialogRef = ref(null);
|
||||
const editDialogRef = ref(null);
|
||||
const showDeleteConfirmationPopup = ref(false);
|
||||
const selectedAutomation = ref({});
|
||||
const toggleModalTitle = ref(t('AUTOMATION.TOGGLE.ACTIVATION_TITLE'));
|
||||
@@ -57,18 +57,18 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
const openAddPopup = () => {
|
||||
showAddPopup.value = true;
|
||||
addDialogRef.value?.open();
|
||||
};
|
||||
const hideAddPopup = () => {
|
||||
showAddPopup.value = false;
|
||||
addDialogRef.value?.close();
|
||||
};
|
||||
|
||||
const openEditPopup = response => {
|
||||
selectedAutomation.value = response;
|
||||
showEditPopup.value = true;
|
||||
selectedAutomation.value = { ...response };
|
||||
editDialogRef.value?.open();
|
||||
};
|
||||
const hideEditPopup = () => {
|
||||
showEditPopup.value = false;
|
||||
editDialogRef.value?.close();
|
||||
};
|
||||
|
||||
const openDeletePopup = response => {
|
||||
@@ -221,17 +221,7 @@ const tableHeaders = computed(() => {
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<woot-modal
|
||||
v-model:show="showAddPopup"
|
||||
size="medium"
|
||||
:on-close="hideAddPopup"
|
||||
>
|
||||
<AddAutomationRule
|
||||
v-if="showAddPopup"
|
||||
:on-close="hideAddPopup"
|
||||
@save-automation="submitAutomation"
|
||||
/>
|
||||
</woot-modal>
|
||||
<AddAutomationRule ref="addDialogRef" @save-automation="submitAutomation" />
|
||||
|
||||
<woot-delete-modal
|
||||
v-model:show="showDeleteConfirmationPopup"
|
||||
@@ -244,18 +234,11 @@ const tableHeaders = computed(() => {
|
||||
:reject-text="deleteRejectText"
|
||||
/>
|
||||
|
||||
<woot-modal
|
||||
v-model:show="showEditPopup"
|
||||
size="medium"
|
||||
:on-close="hideEditPopup"
|
||||
>
|
||||
<EditAutomationRule
|
||||
v-if="showEditPopup"
|
||||
:on-close="hideEditPopup"
|
||||
:selected-response="selectedAutomation"
|
||||
@save-automation="submitAutomation"
|
||||
/>
|
||||
</woot-modal>
|
||||
<EditAutomationRule
|
||||
ref="editDialogRef"
|
||||
:selected-response="selectedAutomation"
|
||||
@save-automation="submitAutomation"
|
||||
/>
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="toggleModalTitle"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import InboxMembersAPI from '../../../../api/inboxMembers';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
import router from '../../../index';
|
||||
import PageHeader from '../SettingsSubPageHeader.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
@@ -13,11 +14,12 @@ export default {
|
||||
components: {
|
||||
PageHeader,
|
||||
NextButton,
|
||||
TagInput,
|
||||
},
|
||||
validations: {
|
||||
selectedAgents: {
|
||||
selectedAgentIds: {
|
||||
isEmpty() {
|
||||
return !!this.selectedAgents.length;
|
||||
return !!this.selectedAgentIds.length;
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -26,7 +28,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedAgents: [],
|
||||
selectedAgentIds: [],
|
||||
isCreating: false,
|
||||
};
|
||||
},
|
||||
@@ -34,18 +36,43 @@ export default {
|
||||
...mapGetters({
|
||||
agentList: 'agents/getAgents',
|
||||
}),
|
||||
selectedAgentNames() {
|
||||
return this.selectedAgentIds.map(
|
||||
id => this.agentList.find(a => a.id === id)?.name ?? ''
|
||||
);
|
||||
},
|
||||
agentMenuItems() {
|
||||
return this.agentList
|
||||
.filter(({ id }) => !this.selectedAgentIds.includes(id))
|
||||
.map(({ id, name, thumbnail, avatar_url }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
action: 'select',
|
||||
thumbnail: { name, src: thumbnail || avatar_url || '' },
|
||||
}));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
handleAgentAdd({ value }) {
|
||||
if (!this.selectedAgentIds.includes(value)) {
|
||||
this.selectedAgentIds.push(value);
|
||||
}
|
||||
},
|
||||
handleAgentRemove(index) {
|
||||
this.selectedAgentIds.splice(index, 1);
|
||||
},
|
||||
async addAgents() {
|
||||
this.isCreating = true;
|
||||
const inboxId = this.$route.params.inbox_id;
|
||||
const selectedAgents = this.selectedAgents.map(x => x.id);
|
||||
|
||||
try {
|
||||
await InboxMembersAPI.update({ inboxId, agentList: selectedAgents });
|
||||
await InboxMembersAPI.update({
|
||||
inboxId,
|
||||
agentList: this.selectedAgentIds,
|
||||
});
|
||||
router.replace({
|
||||
name: 'settings_inbox_finish',
|
||||
params: {
|
||||
@@ -72,25 +99,23 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<label :class="{ error: v$.selectedAgents.$error }">
|
||||
<div class="w-full mb-4">
|
||||
<label :class="{ error: v$.selectedAgentIds.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
|
||||
<multiselect
|
||||
v-model="selectedAgents"
|
||||
:options="agentList"
|
||||
track-by="id"
|
||||
label="name"
|
||||
multiple
|
||||
:close-on-select="false"
|
||||
:clear-on-select="false"
|
||||
hide-selected
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')"
|
||||
@select="v$.selectedAgents.$touch"
|
||||
/>
|
||||
<span v-if="v$.selectedAgents.$error" class="message">
|
||||
<div
|
||||
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
|
||||
>
|
||||
<TagInput
|
||||
:model-value="selectedAgentNames"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')"
|
||||
:menu-items="agentMenuItems"
|
||||
show-dropdown
|
||||
skip-label-dedup
|
||||
@add="handleAgentAdd"
|
||||
@remove="handleAgentRemove"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="v$.selectedAgentIds.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.AGENTS.VALIDATION_ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -12,6 +12,7 @@ import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import router from '../../../../index';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
@@ -21,6 +22,7 @@ export default {
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
NextButton,
|
||||
ComboBox,
|
||||
},
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
@@ -67,6 +69,12 @@ export default {
|
||||
getSelectablePages() {
|
||||
return this.pageList.filter(item => !item.exists);
|
||||
},
|
||||
comboBoxPageOptions() {
|
||||
return this.getSelectablePages.map(({ id, name }) => ({
|
||||
value: id,
|
||||
label: name,
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
@@ -94,9 +102,16 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
setPageName({ name }) {
|
||||
setPageName(pageId) {
|
||||
const page = this.pageList.find(p => p.id === pageId);
|
||||
if (page) {
|
||||
this.selectedPage = page;
|
||||
this.pageName = page.name;
|
||||
} else {
|
||||
this.selectedPage = { name: null, id: null };
|
||||
this.pageName = '';
|
||||
}
|
||||
this.v$.selectedPage.$touch();
|
||||
this.pageName = name;
|
||||
},
|
||||
|
||||
initChannelAuth(channel) {
|
||||
@@ -245,23 +260,20 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div class="w-3/5">
|
||||
<div class="w-full">
|
||||
<div class="w-full mb-2">
|
||||
<div class="input-wrap" :class="{ error: v$.selectedPage.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PAGE') }}
|
||||
<multiselect
|
||||
v-model="selectedPage"
|
||||
close-on-select
|
||||
allow-empty
|
||||
:options="getSelectablePages"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
<span class="text-n-slate-12 text-start">
|
||||
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PAGE') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
:model-value="selectedPage.id"
|
||||
:options="comboBoxPageOptions"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.FB.PICK_A_VALUE')"
|
||||
selected-label
|
||||
@select="setPageName"
|
||||
:has-error="v$.selectedPage.$error"
|
||||
class="[&>div>button]:!bg-n-alpha-black2 mt-1"
|
||||
@update:model-value="setPageName"
|
||||
/>
|
||||
<span v-if="v$.selectedPage.$error" class="message">
|
||||
<span v-if="v$.selectedPage.$error" class="message mt-0.5">
|
||||
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PLACEHOLDER') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+619
-140
@@ -1,122 +1,321 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { minValue } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import SettingsSection from '../../../../../components/SettingsSection.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import assignmentPoliciesAPI from 'dashboard/api/assignmentPolicies';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingsSection,
|
||||
NextButton,
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { isEnterprise } = useConfig();
|
||||
});
|
||||
|
||||
return { v$: useVuelidate(), isEnterprise };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedAgents: [],
|
||||
isAgentListUpdating: false,
|
||||
enableAutoAssignment: false,
|
||||
maxAssignmentLimit: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
agentList: 'agents/getAgents',
|
||||
}),
|
||||
maxAssignmentLimitErrors() {
|
||||
if (this.v$.maxAssignmentLimit.$error) {
|
||||
return this.$t(
|
||||
'INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR'
|
||||
);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
inbox() {
|
||||
this.setDefaults();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setDefaults();
|
||||
},
|
||||
methods: {
|
||||
setDefaults() {
|
||||
this.enableAutoAssignment = this.inbox.enable_auto_assignment;
|
||||
this.maxAssignmentLimit =
|
||||
this.inbox?.auto_assignment_config?.max_assignment_limit || null;
|
||||
this.fetchAttachedAgents();
|
||||
},
|
||||
async fetchAttachedAgents() {
|
||||
try {
|
||||
const response = await this.$store.dispatch('inboxMembers/get', {
|
||||
inboxId: this.inbox.id,
|
||||
});
|
||||
const {
|
||||
data: { payload: inboxMembers },
|
||||
} = response;
|
||||
this.selectedAgents = inboxMembers;
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
},
|
||||
handleEnableAutoAssignment() {
|
||||
this.updateInbox();
|
||||
},
|
||||
async updateAgents() {
|
||||
const agentList = this.selectedAgents.map(el => el.id);
|
||||
this.isAgentListUpdating = true;
|
||||
try {
|
||||
await this.$store.dispatch('inboxMembers/create', {
|
||||
inboxId: this.inbox.id,
|
||||
agentList,
|
||||
});
|
||||
useAlert(this.$t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
this.isAgentListUpdating = false;
|
||||
},
|
||||
async updateInbox() {
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
enable_auto_assignment: this.enableAutoAssignment,
|
||||
auto_assignment_config: {
|
||||
max_assignment_limit: this.maxAssignmentLimit,
|
||||
},
|
||||
};
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
}
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
selectedAgents: {
|
||||
isEmpty() {
|
||||
return !!this.selectedAgents.length;
|
||||
},
|
||||
},
|
||||
maxAssignmentLimit: {
|
||||
minValue: minValue(1),
|
||||
},
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const selectedAgents = ref([]);
|
||||
const isAgentListUpdating = ref(false);
|
||||
const enableAutoAssignment = ref(false);
|
||||
const maxAssignmentLimit = ref(null);
|
||||
const assignmentPolicy = ref(null);
|
||||
const isLoadingPolicy = ref(false);
|
||||
const isDeletingPolicy = ref(false);
|
||||
const showDeleteConfirmModal = ref(false);
|
||||
const availablePolicies = ref([]);
|
||||
const isLoadingPolicies = ref(false);
|
||||
const showPolicyDropdown = ref(false);
|
||||
const isLinkingPolicy = ref(false);
|
||||
|
||||
const agentList = computed(() => store.getters['agents/getAgents']);
|
||||
|
||||
const isFeatureEnabled = feature => {
|
||||
const accountId = Number(route.params.accountId);
|
||||
return store.getters['accounts/isFeatureEnabledonAccount'](
|
||||
accountId,
|
||||
feature
|
||||
);
|
||||
};
|
||||
|
||||
const hasAdvancedAssignment = computed(() => {
|
||||
return isFeatureEnabled('advanced_assignment');
|
||||
});
|
||||
|
||||
const hasAssignmentV2 = computed(() => {
|
||||
return isFeatureEnabled('assignment_v2');
|
||||
});
|
||||
|
||||
const showAdvancedAssignmentUI = computed(() => {
|
||||
return hasAdvancedAssignment.value && hasAssignmentV2.value;
|
||||
});
|
||||
|
||||
const assignmentOrderLabel = computed(() => {
|
||||
if (!assignmentPolicy.value) return '';
|
||||
const priority = assignmentPolicy.value.conversation_priority;
|
||||
if (priority === 'earliest_created') {
|
||||
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.EARLIEST_CREATED');
|
||||
}
|
||||
if (priority === 'longest_waiting') {
|
||||
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.LONGEST_WAITING');
|
||||
}
|
||||
return priority;
|
||||
});
|
||||
|
||||
const assignmentMethodLabel = computed(() => {
|
||||
if (!assignmentPolicy.value) return '';
|
||||
const order = assignmentPolicy.value.assignment_order;
|
||||
if (order === 'round_robin') {
|
||||
return t('INBOX_MGMT.ASSIGNMENT.METHOD.ROUND_ROBIN');
|
||||
}
|
||||
if (order === 'balanced') {
|
||||
return t('INBOX_MGMT.ASSIGNMENT.METHOD.BALANCED');
|
||||
}
|
||||
return order;
|
||||
});
|
||||
|
||||
// Vuelidate validation rules
|
||||
const rules = {
|
||||
maxAssignmentLimit: {
|
||||
minValue: minValue(1),
|
||||
},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, { maxAssignmentLimit });
|
||||
|
||||
const maxAssignmentLimitErrors = computed(() => {
|
||||
if (v$.value.maxAssignmentLimit.$error) {
|
||||
return t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const fetchAttachedAgents = async () => {
|
||||
try {
|
||||
const response = await store.dispatch('inboxMembers/get', {
|
||||
inboxId: props.inbox.id,
|
||||
});
|
||||
const {
|
||||
data: { payload: inboxMembers },
|
||||
} = response;
|
||||
selectedAgents.value = inboxMembers;
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAssignmentPolicy = async () => {
|
||||
if (!props.inbox.id) return;
|
||||
|
||||
isLoadingPolicy.value = true;
|
||||
try {
|
||||
const response = await assignmentPoliciesAPI.getInboxPolicy(props.inbox.id);
|
||||
assignmentPolicy.value = response.data;
|
||||
} catch (error) {
|
||||
// No policy attached, which is fine
|
||||
assignmentPolicy.value = null;
|
||||
} finally {
|
||||
isLoadingPolicy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAvailablePolicies = async () => {
|
||||
isLoadingPolicies.value = true;
|
||||
try {
|
||||
const response = await assignmentPoliciesAPI.get();
|
||||
availablePolicies.value = response.data;
|
||||
} catch (error) {
|
||||
availablePolicies.value = [];
|
||||
} finally {
|
||||
isLoadingPolicies.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const linkPolicyToInbox = async policy => {
|
||||
isLinkingPolicy.value = true;
|
||||
try {
|
||||
await assignmentPoliciesAPI.setInboxPolicy(props.inbox.id, policy.id);
|
||||
assignmentPolicy.value = policy;
|
||||
showPolicyDropdown.value = false;
|
||||
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_ERROR'));
|
||||
} finally {
|
||||
isLinkingPolicy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToAssignmentPolicies = () => {
|
||||
const accountId = route.params.accountId;
|
||||
router.push({
|
||||
name: 'agent_assignment_policy_index',
|
||||
params: { accountId },
|
||||
});
|
||||
};
|
||||
|
||||
const policyMenuItems = computed(() => {
|
||||
const items = availablePolicies.value.map(policy => ({
|
||||
action: 'select_policy',
|
||||
value: policy.id,
|
||||
label: policy.name,
|
||||
icon: 'i-lucide-zap',
|
||||
policy,
|
||||
}));
|
||||
|
||||
items.push({
|
||||
action: 'view_all',
|
||||
value: 'view_all',
|
||||
label: t('INBOX_MGMT.ASSIGNMENT.VIEW_ALL_POLICIES'),
|
||||
icon: 'i-lucide-arrow-right',
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handlePolicyMenuAction = ({ action, policy }) => {
|
||||
if (action === 'select_policy' && policy) {
|
||||
linkPolicyToInbox(policy);
|
||||
} else if (action === 'view_all') {
|
||||
navigateToAssignmentPolicies();
|
||||
}
|
||||
showPolicyDropdown.value = false;
|
||||
};
|
||||
|
||||
const togglePolicyDropdown = () => {
|
||||
if (!showPolicyDropdown.value && availablePolicies.value.length === 0) {
|
||||
fetchAvailablePolicies();
|
||||
}
|
||||
showPolicyDropdown.value = !showPolicyDropdown.value;
|
||||
};
|
||||
|
||||
const closePolicyDropdown = () => {
|
||||
showPolicyDropdown.value = false;
|
||||
};
|
||||
|
||||
const handleToggleAutoAssignment = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
id: props.inbox.id,
|
||||
formData: false,
|
||||
enable_auto_assignment: enableAutoAssignment.value,
|
||||
};
|
||||
await store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const updateAgents = async () => {
|
||||
const agentListIds = selectedAgents.value.map(el => el.id);
|
||||
isAgentListUpdating.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxMembers/create', {
|
||||
inboxId: props.inbox.id,
|
||||
agentList: agentListIds,
|
||||
});
|
||||
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
isAgentListUpdating.value = false;
|
||||
};
|
||||
|
||||
const updateInbox = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
id: props.inbox.id,
|
||||
formData: false,
|
||||
enable_auto_assignment: enableAutoAssignment.value,
|
||||
auto_assignment_config: {
|
||||
max_assignment_limit: maxAssignmentLimit.value,
|
||||
},
|
||||
};
|
||||
await store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToCreatePolicy = () => {
|
||||
const accountId = route.params.accountId;
|
||||
router.push({
|
||||
name: 'agent_assignment_policy_create',
|
||||
params: { accountId },
|
||||
query: { inboxId: props.inbox.id },
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToAssignmentPolicyEdit = () => {
|
||||
if (!assignmentPolicy.value?.id) return;
|
||||
const accountId = route.params.accountId;
|
||||
router.push({
|
||||
name: 'agent_assignment_policy_edit',
|
||||
params: { accountId, id: assignmentPolicy.value.id },
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToBilling = () => {
|
||||
const accountId = route.params.accountId;
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId },
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDeletePolicy = () => {
|
||||
showDeleteConfirmModal.value = true;
|
||||
};
|
||||
|
||||
const cancelDeletePolicy = () => {
|
||||
showDeleteConfirmModal.value = false;
|
||||
};
|
||||
|
||||
const deleteAssignmentPolicy = async () => {
|
||||
if (isDeletingPolicy.value) return;
|
||||
isDeletingPolicy.value = true;
|
||||
try {
|
||||
await assignmentPoliciesAPI.removeInboxPolicy(props.inbox.id);
|
||||
assignmentPolicy.value = null;
|
||||
showDeleteConfirmModal.value = false;
|
||||
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_ERROR'));
|
||||
} finally {
|
||||
isDeletingPolicy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setDefaults = () => {
|
||||
enableAutoAssignment.value = props.inbox.enable_auto_assignment;
|
||||
maxAssignmentLimit.value =
|
||||
props.inbox.auto_assignment_config?.max_assignment_limit || null;
|
||||
fetchAttachedAgents();
|
||||
if (showAdvancedAssignmentUI.value) {
|
||||
fetchAssignmentPolicy();
|
||||
fetchAvailablePolicies();
|
||||
}
|
||||
};
|
||||
|
||||
// Watch only inbox.id to avoid unnecessary refetches when other properties change
|
||||
watch(() => props.inbox.id, setDefaults);
|
||||
|
||||
onMounted(() => {
|
||||
setDefaults();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -138,7 +337,6 @@ export default {
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
@select="v$.selectedAgents.$touch"
|
||||
/>
|
||||
|
||||
<NextButton
|
||||
@@ -152,44 +350,325 @@ export default {
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
|
||||
>
|
||||
<label class="w-3/4 settings-item">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="enableAutoAssignment"
|
||||
<!-- New UI for assignment_v2 -->
|
||||
<template v-if="hasAssignmentV2">
|
||||
<div class="flex items-start gap-3">
|
||||
<Switch
|
||||
v-model="enableAutoAssignment"
|
||||
type="checkbox"
|
||||
@change="handleEnableAutoAssignment"
|
||||
class="flex-shrink-0 mt-0.5"
|
||||
@change="handleToggleAutoAssignment"
|
||||
/>
|
||||
<label for="enableAutoAssignment">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
|
||||
</label>
|
||||
<div class="flex-grow">
|
||||
<label class="text-sm text-n-slate-12 font-medium mb-1">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT') }}
|
||||
</label>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
|
||||
</p>
|
||||
</label>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="enableAutoAssignment" class="mt-6">
|
||||
<!-- Policy Card - When policy is attached -->
|
||||
<div
|
||||
v-if="showAdvancedAssignmentUI && assignmentPolicy"
|
||||
class="p-4 rounded-xl outline-1 outline-n-weak outline bg-n-solid-1 dark:bg-n-slate-1"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 size-12 rounded-xl bg-n-slate-3 flex items-center justify-center"
|
||||
>
|
||||
<span class="i-lucide-zap text-xl text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="text-base font-medium text-n-slate-12 mb-1">
|
||||
{{ assignmentPolicy.name }}
|
||||
</span>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.POLICY_LABEL') }}
|
||||
</p>
|
||||
</div>
|
||||
<NextButton
|
||||
icon="i-lucide-trash-2"
|
||||
ghost
|
||||
ruby
|
||||
sm
|
||||
@click="confirmDeletePolicy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
|
||||
<woot-input
|
||||
v-model="maxAssignmentLimit"
|
||||
type="number"
|
||||
:class="{ error: v$.maxAssignmentLimit.$error }"
|
||||
:error="maxAssignmentLimitErrors"
|
||||
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
|
||||
@blur="v$.maxAssignmentLimit.$touch"
|
||||
/>
|
||||
<ul class="space-y-2 mb-6">
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ assignmentOrderLabel }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ assignmentMethodLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
|
||||
</p>
|
||||
<div class="w-full h-px my-4 bg-n-weak" />
|
||||
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:disabled="v$.maxAssignmentLimit.$invalid"
|
||||
@click="updateInbox"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.ASSIGNMENT.CUSTOMIZE_POLICY')"
|
||||
icon="i-lucide-arrow-right"
|
||||
trailing-icon
|
||||
link
|
||||
class="mb-2"
|
||||
@click="navigateToAssignmentPolicyEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default Policy - When no custom policy attached but feature enabled -->
|
||||
<div
|
||||
v-else-if="
|
||||
showAdvancedAssignmentUI &&
|
||||
!assignmentPolicy &&
|
||||
!isLoadingPolicy
|
||||
"
|
||||
class="rounded-xl outline-1 outline-n-weak outline"
|
||||
>
|
||||
<!-- Default Policy Header -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
|
||||
>
|
||||
<i class="i-lucide-zap text-xl text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h4 class="text-base font-medium text-n-slate-12 mb-1">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_LINKED') }}
|
||||
</h4>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{
|
||||
$t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_DESCRIPTION')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mt-5 flex items-center gap-3">
|
||||
<div
|
||||
v-if="!isLoadingPolicies && availablePolicies.length > 0"
|
||||
v-on-click-outside="closePolicyDropdown"
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-n-brand hover:bg-n-brand/90 rounded-lg transition-colors"
|
||||
@click="togglePolicyDropdown"
|
||||
>
|
||||
<i class="i-lucide-link text-sm" />
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.LINK_EXISTING_POLICY') }}
|
||||
<i
|
||||
class="i-lucide-chevron-down text-sm transition-transform"
|
||||
:class="{ 'rotate-180': showPolicyDropdown }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="showPolicyDropdown"
|
||||
class="top-full left-0 mt-2 min-w-72"
|
||||
:menu-items="policyMenuItems"
|
||||
:is-searching="isLoadingPolicies"
|
||||
@action="handlePolicyMenuAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-n-slate-12 bg-n-slate-3 dark:bg-n-slate-4 hover:bg-n-slate-4 dark:hover:bg-n-slate-5 rounded-lg transition-colors"
|
||||
@click="navigateToCreatePolicy"
|
||||
>
|
||||
<i class="i-lucide-plus text-sm" />
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.CREATE_NEW_POLICY') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default Rules Info -->
|
||||
<div class="px-4 py-4 border-t border-n-weak bg-n-slate-2">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="i-lucide-info text-base text-n-slate-10 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-sm text-n-slate-11 mb-2">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.CURRENT_BEHAVIOR') }}
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default Rules Card - Feature not enabled (no advanced_assignment) -->
|
||||
<div
|
||||
v-else-if="!showAdvancedAssignmentUI"
|
||||
class="p-4 rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
|
||||
>
|
||||
<i class="i-lucide-zap text-xl text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h4 class="text-base font-medium text-n-slate-12 mb-1">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_TITLE') }}
|
||||
</h4>
|
||||
<p class="text-sm text-n-slate-11 mb-4">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_DESCRIPTION') }}
|
||||
</p>
|
||||
|
||||
<ul class="space-y-2 mb-6">
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="w-full h-px bg-n-weak my-4" />
|
||||
|
||||
<!-- Upgrade prompt when advanced_assignment is not enabled -->
|
||||
<div v-if="!hasAdvancedAssignment">
|
||||
<p class="text-sm text-n-slate-11 mb-1">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.UPGRADE_PROMPT') }}
|
||||
</p>
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.ASSIGNMENT.UPGRADE_TO_BUSINESS')"
|
||||
icon="i-lucide-arrow-right"
|
||||
trailing-icon
|
||||
link
|
||||
@click="navigateToBilling"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- Old UI for non-assignment_v2 -->
|
||||
<template v-else>
|
||||
<label class="w-3/4 settings-item">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="enableAutoAssignment"
|
||||
v-model="enableAutoAssignment"
|
||||
type="checkbox"
|
||||
@change="handleToggleAutoAssignment"
|
||||
/>
|
||||
<label for="enableAutoAssignment">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
|
||||
<woot-input
|
||||
v-model="maxAssignmentLimit"
|
||||
type="number"
|
||||
:class="{ error: v$.maxAssignmentLimit.$error }"
|
||||
:error="maxAssignmentLimitErrors"
|
||||
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
|
||||
@blur="v$.maxAssignmentLimit.$touch"
|
||||
/>
|
||||
|
||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
|
||||
</p>
|
||||
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:disabled="v$.maxAssignmentLimit.$invalid"
|
||||
@click="updateInbox"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsSection>
|
||||
|
||||
<woot-modal
|
||||
v-if="showDeleteConfirmModal"
|
||||
:show="showDeleteConfirmModal"
|
||||
:on-close="cancelDeletePolicy"
|
||||
>
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_TITLE') }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11 mb-6 ml-13">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_MESSAGE') }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<NextButton
|
||||
color="slate"
|
||||
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CANCEL')"
|
||||
@click="cancelDeletePolicy"
|
||||
/>
|
||||
<NextButton
|
||||
color="ruby"
|
||||
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CONFIRM_DELETE')"
|
||||
:is-loading="isDeletingPolicy"
|
||||
@click="deleteAssignmentPolicy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user