Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f16c7e59b8 | ||
|
|
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 | ||
|
|
7ade9061a8 |
@@ -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
|
||||
|
||||
@@ -50,6 +50,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)
|
||||
|
||||
@@ -197,6 +197,8 @@ gem 'ai-agents', '>= 0.7.0'
|
||||
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'
|
||||
|
||||
+11
-9
@@ -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,8 +309,8 @@ 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)
|
||||
@@ -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)
|
||||
@@ -541,9 +542,9 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
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)
|
||||
@@ -562,8 +563,8 @@ GEM
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -1037,6 +1038,7 @@ DEPENDENCIES
|
||||
bullet
|
||||
bundle-audit
|
||||
byebug
|
||||
cld3 (~> 3.7)
|
||||
climate_control
|
||||
commonmarker
|
||||
csv-safe
|
||||
|
||||
@@ -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
|
||||
|
||||
-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>
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@ const dialogRef = ref(null);
|
||||
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const meta = useMapGetter('captainResponses/getMeta');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const totalCount = computed(() => meta.value.totalCount || 0);
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
@@ -37,7 +39,7 @@ defineExpose({ dialogRef });
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')"
|
||||
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
|
||||
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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>
|
||||
+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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import BotMetrics from './components/BotMetrics.vue';
|
||||
import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import ReportFilters from './components/ReportFilters.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import ReportContainer from './ReportContainer.vue';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
components: {
|
||||
BotMetrics,
|
||||
ReportHeader,
|
||||
ReportFilterSelector,
|
||||
ReportFilters,
|
||||
ReportContainer,
|
||||
},
|
||||
data() {
|
||||
@@ -88,10 +88,10 @@ export default {
|
||||
<template>
|
||||
<ReportHeader :header-title="$t('BOT_REPORTS.HEADER')" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<ReportFilterSelector
|
||||
:show-agents-filter="false"
|
||||
show-group-by-filter
|
||||
:show-business-hours-switch="false"
|
||||
<ReportFilters
|
||||
:show-entity-filter="false"
|
||||
show-group-by
|
||||
:show-business-hours="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
getAllData() {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import ReportFilters from './components/ReportFilters.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import { generateFileName } from 'dashboard/helper/downloadHelper';
|
||||
@@ -22,7 +22,7 @@ export default {
|
||||
name: 'ConversationReports',
|
||||
components: {
|
||||
ReportHeader,
|
||||
ReportFilterSelector,
|
||||
ReportFilters,
|
||||
ReportContainer,
|
||||
V4Button,
|
||||
},
|
||||
@@ -115,10 +115,10 @@ export default {
|
||||
@click="downloadConversationReports"
|
||||
/>
|
||||
</ReportHeader>
|
||||
<div class="flex flex-col gap-3">
|
||||
<ReportFilterSelector
|
||||
:show-agents-filter="false"
|
||||
show-group-by-filter
|
||||
<div class="flex flex-col">
|
||||
<ReportFilters
|
||||
:show-entity-filter="false"
|
||||
show-group-by
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
<ReportContainer :group-by="groupBy" />
|
||||
|
||||
@@ -145,7 +145,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 px-6 py-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 px-6 py-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 mt-4"
|
||||
>
|
||||
<div
|
||||
v-for="metric in metrics"
|
||||
|
||||
+56
-9
@@ -2,6 +2,8 @@
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { subDays, fromUnixTime } from 'date-fns';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import {
|
||||
buildFilterList,
|
||||
@@ -13,6 +15,12 @@ import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
|
||||
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import {
|
||||
parseReportURLParams,
|
||||
parseFilterURLParams,
|
||||
generateCompleteURLParams,
|
||||
} from '../../helpers/reportFilterHelper';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
|
||||
const props = defineProps({
|
||||
showTeamFilter: {
|
||||
@@ -25,16 +33,29 @@ const emit = defineEmits(['filterChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize from URL params immediately
|
||||
const urlParams = parseReportURLParams(route.query);
|
||||
const urlFilters = parseFilterURLParams(route.query);
|
||||
|
||||
const initialDateRange =
|
||||
urlParams.from && urlParams.to
|
||||
? [fromUnixTime(urlParams.from), fromUnixTime(urlParams.to)]
|
||||
: [subDays(new Date(), 6), new Date()];
|
||||
|
||||
const showDropdownMenu = ref(false);
|
||||
const showSubDropdownMenu = ref(false);
|
||||
const activeFilterType = ref('');
|
||||
const customDateRange = ref([new Date(), new Date()]);
|
||||
const customDateRange = ref(initialDateRange);
|
||||
const selectedDateRange = ref(urlParams.range || DATE_RANGE_TYPES.LAST_7_DAYS);
|
||||
|
||||
const appliedFilters = ref({
|
||||
user_ids: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
rating: null,
|
||||
user_ids: urlFilters.agent_id,
|
||||
inbox_id: urlFilters.inbox_id,
|
||||
team_id: urlFilters.team_id,
|
||||
rating: urlFilters.rating,
|
||||
});
|
||||
|
||||
const agents = computed(() => store.getters['agents/getAgents']);
|
||||
@@ -111,13 +132,17 @@ const activeFilters = computed(() => {
|
||||
const activeKeys = Object.keys(appliedFilters.value).filter(
|
||||
key => appliedFilters.value[key]
|
||||
);
|
||||
|
||||
return activeKeys.map(key => {
|
||||
const filterType = getFilterType(key, 'keyToType');
|
||||
const items = getFilterSource(filterType);
|
||||
const item = getActiveFilter(items, filterType, appliedFilters.value[key]);
|
||||
const displayName =
|
||||
item?.name || item?.title || `ID: ${appliedFilters.value[key]}`;
|
||||
|
||||
return {
|
||||
id: item?.id,
|
||||
name: item?.name || '',
|
||||
id: item?.id || appliedFilters.value[key],
|
||||
name: displayName,
|
||||
type: filterType,
|
||||
options: getFilterOptions(filterType),
|
||||
};
|
||||
@@ -130,7 +155,23 @@ const hasActiveFilters = computed(() =>
|
||||
|
||||
const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
|
||||
|
||||
const updateURLParams = () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
range: selectedDateRange.value,
|
||||
filters: {
|
||||
agent_id: appliedFilters.value.user_ids,
|
||||
inbox_id: appliedFilters.value.inbox_id,
|
||||
team_id: appliedFilters.value.team_id,
|
||||
rating: appliedFilters.value.rating,
|
||||
},
|
||||
});
|
||||
router.replace({ query: params });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
updateURLParams();
|
||||
emit('filterChange', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
@@ -200,7 +241,9 @@ const openActiveFilterDropdown = filterType => {
|
||||
};
|
||||
|
||||
const onDateRangeChange = value => {
|
||||
customDateRange.value = value;
|
||||
const [startDate, endDate, rangeType] = value;
|
||||
customDateRange.value = [startDate, endDate];
|
||||
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
@@ -211,7 +254,11 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
|
||||
<WootDatePicker @date-range-changed="onDateRangeChange" />
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col flex-wrap items-start gap-2 md:items-center md:flex-nowrap md:flex-row"
|
||||
|
||||
-228
@@ -1,228 +0,0 @@
|
||||
<script>
|
||||
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
|
||||
import ReportsFiltersDateRange from './Filters/DateRange.vue';
|
||||
import ReportsFiltersDateGroupBy from './Filters/DateGroupBy.vue';
|
||||
import ReportsFiltersAgents from './Filters/Agents.vue';
|
||||
import ReportsFiltersLabels from './Filters/Labels.vue';
|
||||
import ReportsFiltersInboxes from './Filters/Inboxes.vue';
|
||||
import ReportsFiltersTeams from './Filters/Teams.vue';
|
||||
import ReportsFiltersRatings from './Filters/Ratings.vue';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import { DATE_RANGE_OPTIONS } from '../constants';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootDateRangePicker,
|
||||
ReportsFiltersDateRange,
|
||||
ReportsFiltersDateGroupBy,
|
||||
ReportsFiltersAgents,
|
||||
ReportsFiltersLabels,
|
||||
ReportsFiltersInboxes,
|
||||
ReportsFiltersTeams,
|
||||
ReportsFiltersRatings,
|
||||
ToggleSwitch,
|
||||
},
|
||||
props: {
|
||||
showGroupByFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showAgentsFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showLabelsFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showInboxFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showRatingFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTeamFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showBusinessHoursSwitch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['filterChange'],
|
||||
data() {
|
||||
return {
|
||||
// default value, need not be translated
|
||||
selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
|
||||
selectedGroupByFilter: null,
|
||||
selectedLabel: null,
|
||||
selectedInbox: null,
|
||||
selectedTeam: null,
|
||||
selectedRating: null,
|
||||
selectedAgents: [],
|
||||
customDateRange: [new Date(), new Date()],
|
||||
businessHoursSelected: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isDateRangeSelected() {
|
||||
return (
|
||||
this.selectedDateRange.id === DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE.id
|
||||
);
|
||||
},
|
||||
isGroupByPossible() {
|
||||
return this.selectedDateRange.id !== DATE_RANGE_OPTIONS.LAST_7_DAYS.id;
|
||||
},
|
||||
to() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return getUnixEndOfDay(this.customDateRange[1]);
|
||||
}
|
||||
return getUnixEndOfDay(new Date());
|
||||
},
|
||||
from() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return getUnixStartOfDay(this.customDateRange[0]);
|
||||
}
|
||||
|
||||
const { offset } = this.selectedDateRange;
|
||||
const fromDate = subDays(new Date(), offset);
|
||||
return getUnixStartOfDay(fromDate);
|
||||
},
|
||||
validGroupOptions() {
|
||||
return this.selectedDateRange.groupByOptions;
|
||||
},
|
||||
validGroupBy() {
|
||||
if (!this.selectedGroupByFilter) {
|
||||
return this.validGroupOptions[0];
|
||||
}
|
||||
|
||||
const validIds = this.validGroupOptions.map(opt => opt.id);
|
||||
if (validIds.includes(this.selectedGroupByFilter.id)) {
|
||||
return this.selectedGroupByFilter;
|
||||
}
|
||||
return this.validGroupOptions[0];
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.emitChange();
|
||||
},
|
||||
methods: {
|
||||
emitChange() {
|
||||
const {
|
||||
from,
|
||||
to,
|
||||
selectedGroupByFilter: groupBy,
|
||||
businessHoursSelected: businessHours,
|
||||
selectedAgents,
|
||||
selectedLabel,
|
||||
selectedInbox,
|
||||
selectedTeam,
|
||||
selectedRating,
|
||||
} = this;
|
||||
this.$emit('filterChange', {
|
||||
from,
|
||||
to,
|
||||
groupBy,
|
||||
businessHours,
|
||||
selectedAgents,
|
||||
selectedLabel,
|
||||
selectedInbox,
|
||||
selectedTeam,
|
||||
selectedRating,
|
||||
});
|
||||
},
|
||||
onDateRangeChange(selectedRange) {
|
||||
this.selectedDateRange = selectedRange;
|
||||
this.selectedGroupByFilter = this.validGroupBy;
|
||||
this.emitChange();
|
||||
},
|
||||
onCustomDateRangeChange(value) {
|
||||
this.customDateRange = value;
|
||||
this.selectedGroupByFilter = this.validGroupBy;
|
||||
this.emitChange();
|
||||
},
|
||||
onGroupingChange(payload) {
|
||||
this.selectedGroupByFilter = payload;
|
||||
this.emitChange();
|
||||
},
|
||||
handleAgentsFilterSelection(selectedAgents) {
|
||||
this.selectedAgents = selectedAgents;
|
||||
this.emitChange();
|
||||
},
|
||||
handleLabelsFilterSelection(selectedLabel) {
|
||||
this.selectedLabel = selectedLabel;
|
||||
this.emitChange();
|
||||
},
|
||||
handleInboxFilterSelection(selectedInbox) {
|
||||
this.selectedInbox = selectedInbox;
|
||||
this.emitChange();
|
||||
},
|
||||
handleTeamFilterSelection(selectedTeam) {
|
||||
this.selectedTeam = selectedTeam;
|
||||
this.emitChange();
|
||||
},
|
||||
handleRatingFilterSelection(selectedRating) {
|
||||
this.selectedRating = selectedRating;
|
||||
this.emitChange();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between gap-3 md:flex-row">
|
||||
<div
|
||||
class="w-full grid gap-y-2 gap-x-1.5 grid-cols-[repeat(auto-fill,minmax(250px,1fr))]"
|
||||
>
|
||||
<ReportsFiltersDateRange @on-range-change="onDateRangeChange" />
|
||||
<WootDateRangePicker
|
||||
v-if="isDateRangeSelected"
|
||||
show-range
|
||||
class="no-margin auto-width"
|
||||
:value="customDateRange"
|
||||
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
|
||||
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
|
||||
@change="onCustomDateRangeChange"
|
||||
/>
|
||||
<ReportsFiltersDateGroupBy
|
||||
v-if="showGroupByFilter && isGroupByPossible"
|
||||
:valid-group-options="validGroupOptions"
|
||||
:selected-option="selectedGroupByFilter"
|
||||
@on-grouping-change="onGroupingChange"
|
||||
/>
|
||||
<ReportsFiltersAgents
|
||||
v-if="showAgentsFilter"
|
||||
@agents-filter-selection="handleAgentsFilterSelection"
|
||||
/>
|
||||
<ReportsFiltersLabels
|
||||
v-if="showLabelsFilter"
|
||||
@labels-filter-selection="handleLabelsFilterSelection"
|
||||
/>
|
||||
<ReportsFiltersTeams
|
||||
v-if="showTeamFilter"
|
||||
@team-filter-selection="handleTeamFilterSelection"
|
||||
/>
|
||||
<ReportsFiltersInboxes
|
||||
v-if="showInboxFilter"
|
||||
@inbox-filter-selection="handleInboxFilterSelection"
|
||||
/>
|
||||
<ReportsFiltersRatings
|
||||
v-if="showRatingFilter"
|
||||
@rating-filter-selection="handleRatingFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showBusinessHoursSwitch" class="flex items-center">
|
||||
<span class="mx-2 text-sm whitespace-nowrap">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<ToggleSwitch v-model="businessHoursSelected" @change="emitChange" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersAgents',
|
||||
emits: ['agentsFilterSelection'],
|
||||
data() {
|
||||
return {
|
||||
selectedOptions: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'agents/getAgents',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('agentsFilterSelection', this.selectedOptions);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOptions"
|
||||
class="no-margin"
|
||||
:options="options"
|
||||
track-by="id"
|
||||
label="name"
|
||||
multiple
|
||||
:close-on-select="false"
|
||||
:clear-on-select="false"
|
||||
hide-selected
|
||||
:placeholder="$t('CSAT_REPORTS.FILTERS.AGENTS.PLACEHOLDER')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
@update:model-value="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
<script>
|
||||
import { GROUP_BY_OPTIONS } from '../../constants';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersDateGroupBy',
|
||||
props: {
|
||||
validGroupOptions: {
|
||||
type: Array,
|
||||
default: () => [GROUP_BY_OPTIONS.DAY],
|
||||
},
|
||||
selectedOption: {
|
||||
type: Object,
|
||||
default: () => GROUP_BY_OPTIONS.DAY,
|
||||
},
|
||||
},
|
||||
emits: ['onGroupingChange'],
|
||||
data() {
|
||||
return {
|
||||
currentSelectedFilter: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
translatedOptions() {
|
||||
return this.validGroupOptions.map(option => ({
|
||||
...option,
|
||||
groupBy: this.$t(option.translationKey),
|
||||
}));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedOption: {
|
||||
handler() {
|
||||
this.currentSelectedFilter = {
|
||||
...this.selectedOption,
|
||||
groupBy: this.$t(this.selectedOption.translationKey),
|
||||
};
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
changeFilterSelection(selectedFilter) {
|
||||
this.groupByOptions = this.$emit('onGroupingChange', selectedFilter);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<p aria-hidden="true" class="hidden">
|
||||
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
class="no-margin"
|
||||
track-by="id"
|
||||
label="groupBy"
|
||||
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
|
||||
:options="translatedOptions"
|
||||
:allow-empty="false"
|
||||
:show-labels="false"
|
||||
@select="changeFilterSelection"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DATE_RANGE_OPTIONS } from '../../constants';
|
||||
|
||||
const emit = defineEmits(['onRangeChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const options = computed(() =>
|
||||
Object.values(DATE_RANGE_OPTIONS).map(option => ({
|
||||
...option,
|
||||
name: t(option.translationKey),
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedId = ref(Object.values(DATE_RANGE_OPTIONS)[0].id);
|
||||
|
||||
const selectedOption = computed({
|
||||
get() {
|
||||
return options.value.find(o => o.id === selectedId.value);
|
||||
},
|
||||
set(val) {
|
||||
selectedId.value = val.id;
|
||||
},
|
||||
});
|
||||
|
||||
const updateRange = range => {
|
||||
selectedOption.value = range;
|
||||
emit('onRangeChange', range);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:options="options"
|
||||
:searchable="false"
|
||||
:allow-empty="false"
|
||||
@select="updateRange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersInboxes',
|
||||
emits: ['inboxFilterSelection'],
|
||||
data() {
|
||||
return {
|
||||
selectedOption: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'inboxes/getInboxes',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxes/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('inboxFilterSelection', this.selectedOption);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:placeholder="$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:options="options"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersLabels',
|
||||
emits: ['labelsFilterSelection'],
|
||||
data() {
|
||||
return {
|
||||
selectedOption: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'labels/getLabels',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('labels/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('labelsFilterSelection', this.selectedOption);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:placeholder="$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
label="title"
|
||||
track-by="id"
|
||||
:options="options"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="handleInput"
|
||||
>
|
||||
<template #singleLabel="props">
|
||||
<div class="flex items-center min-w-0 gap-2">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="w-5 h-5 rounded-full"
|
||||
/>
|
||||
<span class="my-0 text-n-slate-12">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="props">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="flex-shrink-0 w-5 h-5 border border-solid rounded-full border-n-weak"
|
||||
/>
|
||||
|
||||
<span class="my-0 text-n-slate-12 truncate min-w-0">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
</template>
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
<script>
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'ReportFiltersRatings',
|
||||
emits: ['ratingFilterSelection'],
|
||||
data() {
|
||||
const translatedOptions = CSAT_RATINGS.reverse().map(option => ({
|
||||
...option,
|
||||
label: this.$t(option.translationKey),
|
||||
}));
|
||||
|
||||
return {
|
||||
selectedOption: null,
|
||||
options: translatedOptions,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleInput(selectedRating) {
|
||||
this.$emit('ratingFilterSelection', selectedRating);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:option-height="24"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
|
||||
:options="options"
|
||||
:show-labels="false"
|
||||
track-by="value"
|
||||
label="label"
|
||||
@update:model-value="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ReportsFiltersTeams',
|
||||
emits: ['teamFilterSelection'],
|
||||
data() {
|
||||
return {
|
||||
selectedOption: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
options: 'teams/getTeams',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('teams/get');
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('teamFilterSelection', this.selectedOption);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedOption"
|
||||
class="no-margin"
|
||||
:placeholder="$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL')"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:options="options"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+7
-3
@@ -8,8 +8,8 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
type: [Number, null],
|
||||
default: null,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
@@ -35,6 +35,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showClearFilter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -60,7 +64,7 @@ const closeDropdown = () => emit('closeDropdown');
|
||||
<FilterListDropdown
|
||||
v-if="options"
|
||||
v-on-clickaway="closeDropdown"
|
||||
show-clear-filter
|
||||
:show-clear-filter="showClearFilter"
|
||||
:list-items="options"
|
||||
:active-filter-id="id"
|
||||
:input-placeholder="placeholder"
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
} from '../helpers/reportFilterHelper';
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const customDateRange = ref([subDays(new Date(), 6), new Date()]);
|
||||
const selectedDateRange = ref(DATE_RANGE_TYPES.LAST_7_DAYS);
|
||||
const businessHoursSelected = ref(false);
|
||||
|
||||
const updateURLParams = () => {
|
||||
const params = generateReportURLParams({
|
||||
from: getUnixStartOfDay(customDateRange.value[0]),
|
||||
to: getUnixEndOfDay(customDateRange.value[1]),
|
||||
businessHours: businessHoursSelected.value,
|
||||
range: selectedDateRange.value,
|
||||
});
|
||||
|
||||
router.replace({ query: { ...params } });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
updateURLParams();
|
||||
emit('filterChange', {
|
||||
from: getUnixStartOfDay(customDateRange.value[0]),
|
||||
to: getUnixEndOfDay(customDateRange.value[1]),
|
||||
businessHours: businessHoursSelected.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onDateRangeChange = value => {
|
||||
const [startDate, endDate, rangeType] = value;
|
||||
customDateRange.value = [startDate, endDate];
|
||||
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const onBusinessHoursToggle = () => {
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
const urlParams = parseReportURLParams(route.query);
|
||||
|
||||
// Set the range type first
|
||||
if (urlParams.range) {
|
||||
selectedDateRange.value = urlParams.range;
|
||||
}
|
||||
|
||||
// Restore dates from URL if available
|
||||
if (urlParams.from && urlParams.to) {
|
||||
customDateRange.value = [
|
||||
new Date(urlParams.from * 1000),
|
||||
new Date(urlParams.to * 1000),
|
||||
];
|
||||
}
|
||||
|
||||
if (urlParams.businessHours) {
|
||||
businessHoursSelected.value = urlParams.businessHours;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
emitChange();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-between gap-3 md:flex-row"
|
||||
:class="{ 'pointer-events-none opacity-50': disabled }"
|
||||
>
|
||||
<div class="flex flex-col flex-wrap items-start gap-2 md:flex-row">
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="mx-2 text-sm whitespace-nowrap">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<ToggleSwitch
|
||||
v-model="businessHoursSelected"
|
||||
@change="onBusinessHoursToggle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+361
-328
@@ -1,349 +1,382 @@
|
||||
<script>
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
|
||||
import differenceInDays from 'date-fns/differenceInDays';
|
||||
import ActiveFilterChip from './Filters/v3/ActiveFilterChip.vue';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
const CUSTOM_DATE_RANGE_ID = 5;
|
||||
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
} from '../helpers/reportFilterHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootDateRangePicker,
|
||||
Avatar,
|
||||
ToggleSwitch,
|
||||
const props = defineProps({
|
||||
filterType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
validator: value =>
|
||||
['teams', 'inboxes', 'labels', 'agents', ''].includes(value),
|
||||
},
|
||||
props: {
|
||||
currentFilter: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
filterItemsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
groupByFilterItemsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'agent',
|
||||
},
|
||||
selectedGroupByFilter: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
selectedItem: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
emits: [
|
||||
'businessHoursToggle',
|
||||
'dateRangeChange',
|
||||
'filterChange',
|
||||
'groupByFilterChange',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
currentSelectedFilter: this.currentFilter || null,
|
||||
currentDateRangeSelection: {
|
||||
id: 0,
|
||||
name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||
},
|
||||
customDateRange: [new Date(), new Date()],
|
||||
currentSelectedGroupByFilter: null,
|
||||
businessHoursSelected: false,
|
||||
};
|
||||
showGroupBy: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
computed: {
|
||||
dateRange() {
|
||||
return [
|
||||
{ id: 0, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS') },
|
||||
{ id: 1, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS') },
|
||||
{ id: 2, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_3_MONTHS') },
|
||||
{ id: 3, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_6_MONTHS') },
|
||||
{ id: 4, name: this.$t('REPORT.DATE_RANGE_OPTIONS.LAST_YEAR') },
|
||||
{ id: 5, name: this.$t('REPORT.DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE') },
|
||||
];
|
||||
},
|
||||
isDateRangeSelected() {
|
||||
return this.currentDateRangeSelection.id === CUSTOM_DATE_RANGE_ID;
|
||||
},
|
||||
to() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return this.toCustomDate(this.customDateRange[1]);
|
||||
}
|
||||
return this.toCustomDate(new Date());
|
||||
},
|
||||
from() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return this.fromCustomDate(this.customDateRange[0]);
|
||||
}
|
||||
const dateRange = {
|
||||
0: 6,
|
||||
1: 29,
|
||||
2: 89,
|
||||
3: 179,
|
||||
4: 364,
|
||||
};
|
||||
const diff = dateRange[this.currentDateRangeSelection.id];
|
||||
const fromDate = subDays(new Date(), diff);
|
||||
return this.fromCustomDate(fromDate);
|
||||
},
|
||||
multiselectLabel() {
|
||||
const typeLabels = {
|
||||
agent: this.$t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL'),
|
||||
label: this.$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL'),
|
||||
inbox: this.$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL'),
|
||||
team: this.$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL'),
|
||||
};
|
||||
return typeLabels[this.type] || this.$t('FORMS.MULTISELECT.SELECT_ONE');
|
||||
},
|
||||
groupBy() {
|
||||
if (this.isDateRangeSelected) {
|
||||
return GROUP_BY_FILTER[4].period;
|
||||
}
|
||||
const groupRange = {
|
||||
0: GROUP_BY_FILTER[1].period,
|
||||
1: GROUP_BY_FILTER[2].period,
|
||||
2: GROUP_BY_FILTER[3].period,
|
||||
3: GROUP_BY_FILTER[3].period,
|
||||
4: GROUP_BY_FILTER[4].period,
|
||||
};
|
||||
return groupRange[this.currentDateRangeSelection.id];
|
||||
},
|
||||
notLast7Days() {
|
||||
return this.groupBy !== GROUP_BY_FILTER[1].period;
|
||||
},
|
||||
showBusinessHours: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
watch: {
|
||||
filterItemsList(val) {
|
||||
this.currentSelectedFilter = !this.currentFilter
|
||||
? val[0]
|
||||
: this.currentFilter;
|
||||
this.changeFilterSelection();
|
||||
},
|
||||
groupByFilterItemsList() {
|
||||
this.currentSelectedGroupByFilter = this.selectedGroupByFilter;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.onDateRangeChange();
|
||||
},
|
||||
methods: {
|
||||
onDateRangeChange() {
|
||||
this.$emit('dateRangeChange', {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
groupBy: this.groupBy,
|
||||
});
|
||||
},
|
||||
onBusinessHoursToggle() {
|
||||
this.$emit('businessHoursToggle', this.businessHoursSelected);
|
||||
},
|
||||
fromCustomDate(date) {
|
||||
return getUnixTime(startOfDay(date));
|
||||
},
|
||||
toCustomDate(date) {
|
||||
return getUnixTime(endOfDay(date));
|
||||
},
|
||||
changeDateSelection(selectedRange) {
|
||||
this.currentDateRangeSelection = selectedRange;
|
||||
this.onDateRangeChange();
|
||||
},
|
||||
changeFilterSelection() {
|
||||
this.$emit('filterChange', this.currentSelectedFilter);
|
||||
},
|
||||
onChange(value) {
|
||||
this.customDateRange = value;
|
||||
this.onDateRangeChange();
|
||||
},
|
||||
changeGroupByFilterSelection() {
|
||||
this.$emit('groupByFilterChange', this.currentSelectedGroupByFilter);
|
||||
},
|
||||
showEntityFilter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const buildReportFilterList = (items, type) => {
|
||||
if (!Array.isArray(items)) return [];
|
||||
|
||||
return items.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name || item.title,
|
||||
type,
|
||||
}));
|
||||
};
|
||||
|
||||
const getReportFilterKey = filterType => {
|
||||
const keyMap = {
|
||||
teams: 'team_id',
|
||||
inboxes: 'inbox_id',
|
||||
labels: 'label_id',
|
||||
agents: 'agent_id',
|
||||
};
|
||||
return keyMap[filterType] || '';
|
||||
};
|
||||
|
||||
const getFilterKey = () => getReportFilterKey(props.filterType);
|
||||
|
||||
const showSubDropdownMenu = ref(false);
|
||||
const showGroupByDropdown = ref(false);
|
||||
const activeFilterType = ref('');
|
||||
const customDateRange = ref([subDays(new Date(), 6), new Date()]);
|
||||
const selectedDateRange = ref(DATE_RANGE_TYPES.LAST_7_DAYS);
|
||||
const businessHoursSelected = ref(false);
|
||||
const groupBy = ref(GROUP_BY_FILTER[1]);
|
||||
const groupByfilterItemsList = ref([{ id: 1, name: 'Day' }]);
|
||||
|
||||
const appliedFilters = ref(
|
||||
props.showEntityFilter
|
||||
? { [getFilterKey()]: props.selectedItem?.id || null }
|
||||
: {}
|
||||
);
|
||||
|
||||
const filterSource = computed(() => {
|
||||
const sources = {
|
||||
teams: store.getters['teams/getTeams'],
|
||||
inboxes: store.getters['inboxes/getInboxes'],
|
||||
labels: store.getters['labels/getLabels'],
|
||||
agents: store.getters['agents/getAgents'],
|
||||
};
|
||||
return sources[props.filterType] || [];
|
||||
});
|
||||
|
||||
const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
|
||||
const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
|
||||
|
||||
const daysDifference = computed(() => {
|
||||
return differenceInDays(customDateRange.value[1], customDateRange.value[0]);
|
||||
});
|
||||
|
||||
const isGroupByPossible = computed(() => {
|
||||
return props.showGroupBy && daysDifference.value >= 29;
|
||||
});
|
||||
|
||||
const GROUP_BY_OPTIONS = computed(() => ({
|
||||
WEEK: [
|
||||
{ id: 1, name: t('REPORT.GROUPING_OPTIONS.DAY') },
|
||||
{ id: 2, name: t('REPORT.GROUPING_OPTIONS.WEEK') },
|
||||
],
|
||||
MONTH: [
|
||||
{ id: 1, name: t('REPORT.GROUPING_OPTIONS.DAY') },
|
||||
{ id: 2, name: t('REPORT.GROUPING_OPTIONS.WEEK') },
|
||||
{ id: 3, name: t('REPORT.GROUPING_OPTIONS.MONTH') },
|
||||
],
|
||||
YEAR: [
|
||||
{ id: 2, name: t('REPORT.GROUPING_OPTIONS.WEEK') },
|
||||
{ id: 3, name: t('REPORT.GROUPING_OPTIONS.MONTH') },
|
||||
{ id: 4, name: t('REPORT.GROUPING_OPTIONS.YEAR') },
|
||||
],
|
||||
}));
|
||||
|
||||
const fetchFilterItems = () => {
|
||||
const days = daysDifference.value;
|
||||
if (days >= 364) return GROUP_BY_OPTIONS.value.YEAR;
|
||||
if (days >= 90) return GROUP_BY_OPTIONS.value.MONTH;
|
||||
if (days >= 29) return GROUP_BY_OPTIONS.value.WEEK;
|
||||
return GROUP_BY_OPTIONS.value.WEEK;
|
||||
};
|
||||
|
||||
const filterOptions = computed(() =>
|
||||
buildReportFilterList(filterSource.value, props.filterType)
|
||||
);
|
||||
|
||||
const filterPlaceholder = computed(() => {
|
||||
const placeholders = {
|
||||
teams: 'TEAM_REPORTS.FILTERS.INPUT_PLACEHOLDER.TEAMS',
|
||||
inboxes: 'INBOX_REPORTS.FILTERS.INPUT_PLACEHOLDER.INBOXES',
|
||||
labels: 'LABEL_REPORTS.FILTERS.INPUT_PLACEHOLDER.LABELS',
|
||||
agents: 'AGENT_REPORTS.FILTERS.INPUT_PLACEHOLDER.AGENTS',
|
||||
};
|
||||
return t(placeholders[props.filterType] || '');
|
||||
});
|
||||
|
||||
const defaultFilterLabel = computed(() => {
|
||||
const labelKeys = {
|
||||
teams: 'TEAM_REPORTS.FILTER_DROPDOWN_LABEL',
|
||||
inboxes: 'INBOX_REPORTS.FILTER_DROPDOWN_LABEL',
|
||||
labels: 'LABEL_REPORTS.FILTER_DROPDOWN_LABEL',
|
||||
agents: 'AGENT_REPORTS.FILTER_DROPDOWN_LABEL',
|
||||
};
|
||||
return t(labelKeys[props.filterType] || 'FORMS.MULTISELECT.SELECT_ONE');
|
||||
});
|
||||
|
||||
const selectedFilterName = computed(() => {
|
||||
const filterKey = getFilterKey();
|
||||
const selectedId = appliedFilters.value[filterKey];
|
||||
|
||||
if (!selectedId) {
|
||||
return defaultFilterLabel.value;
|
||||
}
|
||||
|
||||
const selectedItem = filterOptions.value.find(item => item.id === selectedId);
|
||||
return selectedItem?.name || defaultFilterLabel.value;
|
||||
});
|
||||
|
||||
const updateURLParams = () => {
|
||||
const params = generateReportURLParams({
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
businessHours: businessHoursSelected.value,
|
||||
groupBy: isGroupByPossible.value ? groupBy.value.id : null,
|
||||
range: selectedDateRange.value,
|
||||
});
|
||||
|
||||
router.replace({ query: { ...params } });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
const payload = {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
businessHours: businessHoursSelected.value,
|
||||
};
|
||||
|
||||
if (props.showGroupBy) {
|
||||
// Always emit groupBy, default to day when range is too short
|
||||
payload.groupBy = isGroupByPossible.value
|
||||
? groupBy.value
|
||||
: GROUP_BY_FILTER[1];
|
||||
}
|
||||
|
||||
if (props.showEntityFilter) {
|
||||
const filterKey = getFilterKey();
|
||||
const selectedValue = appliedFilters.value[filterKey];
|
||||
|
||||
if (selectedValue) {
|
||||
payload[props.filterType] =
|
||||
props.filterType === 'agents'
|
||||
? [{ id: selectedValue }]
|
||||
: { id: selectedValue };
|
||||
}
|
||||
}
|
||||
|
||||
updateURLParams();
|
||||
emit('filterChange', payload);
|
||||
};
|
||||
|
||||
const closeActiveFilterDropdown = () => {
|
||||
showSubDropdownMenu.value = false;
|
||||
activeFilterType.value = '';
|
||||
};
|
||||
|
||||
const openActiveFilterDropdown = filterType => {
|
||||
showGroupByDropdown.value = false;
|
||||
activeFilterType.value = filterType;
|
||||
showSubDropdownMenu.value = !showSubDropdownMenu.value;
|
||||
};
|
||||
|
||||
const addFilter = item => {
|
||||
const filterKey = getFilterKey();
|
||||
appliedFilters.value[filterKey] = item.id;
|
||||
closeActiveFilterDropdown();
|
||||
emitChange();
|
||||
|
||||
// Navigate to the new entity's route
|
||||
const routeNameMap = {
|
||||
teams: 'team_reports_show',
|
||||
inboxes: 'inbox_reports_show',
|
||||
labels: 'label_reports_show',
|
||||
agents: 'agent_reports_show',
|
||||
};
|
||||
|
||||
const routeName = routeNameMap[props.filterType];
|
||||
if (routeName) {
|
||||
router.push({
|
||||
name: routeName,
|
||||
params: { ...route.params, id: item.id },
|
||||
query: route.query,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDateRangeChange = value => {
|
||||
const [startDate, endDate, rangeType] = value;
|
||||
customDateRange.value = [startDate, endDate];
|
||||
selectedDateRange.value = rangeType || DATE_RANGE_TYPES.CUSTOM_RANGE;
|
||||
groupByfilterItemsList.value = fetchFilterItems();
|
||||
const filterItems = groupByfilterItemsList.value.filter(
|
||||
item => item.id === groupBy.value.id
|
||||
);
|
||||
if (filterItems.length === 0) {
|
||||
groupBy.value = GROUP_BY_FILTER[groupByfilterItemsList.value[0].id];
|
||||
}
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const onBusinessHoursToggle = () => {
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const onGroupByFilterChange = payload => {
|
||||
groupBy.value = GROUP_BY_FILTER[payload.id];
|
||||
showGroupByDropdown.value = false;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const toggleGroupByDropdown = () => {
|
||||
showGroupByDropdown.value = !showGroupByDropdown.value;
|
||||
};
|
||||
|
||||
const closeGroupByDropdown = () => {
|
||||
showGroupByDropdown.value = false;
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
const urlParams = parseReportURLParams(route.query);
|
||||
|
||||
// Set the range type first
|
||||
if (urlParams.range) {
|
||||
selectedDateRange.value = urlParams.range;
|
||||
}
|
||||
|
||||
// Restore dates from URL if available
|
||||
if (urlParams.from && urlParams.to) {
|
||||
customDateRange.value = [
|
||||
new Date(urlParams.from * 1000),
|
||||
new Date(urlParams.to * 1000),
|
||||
];
|
||||
}
|
||||
|
||||
if (urlParams.businessHours) {
|
||||
businessHoursSelected.value = urlParams.businessHours;
|
||||
}
|
||||
|
||||
if (urlParams.groupBy) {
|
||||
const groupByValue = GROUP_BY_FILTER[urlParams.groupBy];
|
||||
if (groupByValue) {
|
||||
groupBy.value = groupByValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize entity filter from route params (not URL query)
|
||||
if (props.showEntityFilter && route.params.id) {
|
||||
const filterKey = getFilterKey();
|
||||
appliedFilters.value[filterKey] = Number(route.params.id);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
groupByfilterItemsList.value = fetchFilterItems();
|
||||
emitChange();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-y-0.5 gap-x-2">
|
||||
<div v-if="type === 'agent'" class="multiselect-wrap--small">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
:placeholder="multiselectLabel"
|
||||
label="name"
|
||||
track-by="id"
|
||||
:options="filterItemsList"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="changeFilterSelection"
|
||||
>
|
||||
<template #singleLabel="props">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Avatar
|
||||
:src="props.option.thumbnail"
|
||||
:status="props.option.availability_status"
|
||||
:name="props.option.name"
|
||||
:size="22"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<span class="my-0 text-n-slate-12 truncate">{{
|
||||
props.option.name
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #options="props">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar
|
||||
:src="props.option.thumbnail"
|
||||
:status="props.option.availability_status"
|
||||
:name="props.option.name"
|
||||
:size="22"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<p class="my-0 text-n-slate-12">
|
||||
{{ props.option.name }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-3 lg:flex-row">
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
/>
|
||||
|
||||
<div v-else-if="type === 'label'" class="multiselect-wrap--small">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
:placeholder="multiselectLabel"
|
||||
label="title"
|
||||
track-by="id"
|
||||
:options="filterItemsList"
|
||||
:option-height="24"
|
||||
:show-labels="false"
|
||||
@update:model-value="changeFilterSelection"
|
||||
>
|
||||
<template #singleLabel="props">
|
||||
<div class="flex items-center min-w-0 gap-2">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="w-5 h-5 rounded-full"
|
||||
/>
|
||||
|
||||
<span class="my-0 text-n-slate-12 truncate">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="props">
|
||||
<div class="flex items-center min-w-0 gap-2">
|
||||
<div
|
||||
:style="{ backgroundColor: props.option.color }"
|
||||
class="flex-shrink-0 w-5 h-5 border border-solid rounded-full border-n-weak"
|
||||
/>
|
||||
|
||||
<span class="my-0 text-n-slate-12 truncate">
|
||||
{{ props.option.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
|
||||
<div v-else class="multiselect-wrap--small">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
<template v-if="type === 'inbox'">
|
||||
{{ $t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</template>
|
||||
<template v-else-if="type === 'team'">
|
||||
{{ $t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('FORMS.MULTISELECT.SELECT_ONE') }}
|
||||
</template>
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedFilter"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="multiselectLabel"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:options="filterItemsList"
|
||||
:searchable="false"
|
||||
:allow-empty="false"
|
||||
@update:model-value="changeFilterSelection"
|
||||
<div class="flex gap-2 items-center w-full">
|
||||
<ActiveFilterChip
|
||||
v-if="showEntityFilter"
|
||||
:id="appliedFilters[getFilterKey()]"
|
||||
:name="selectedFilterName"
|
||||
:type="filterType"
|
||||
:options="filterOptions"
|
||||
:active-filter-type="activeFilterType"
|
||||
:show-menu="showSubDropdownMenu"
|
||||
:placeholder="filterPlaceholder"
|
||||
:show-clear-filter="false"
|
||||
enable-search
|
||||
@toggle-dropdown="openActiveFilterDropdown"
|
||||
@close-dropdown="closeActiveFilterDropdown"
|
||||
@add-filter="addFilter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="multiselect-wrap--small">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('REPORT.DURATION_FILTER_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentDateRangeSelection"
|
||||
track-by="name"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:options="dateRange"
|
||||
:searchable="false"
|
||||
:allow-empty="false"
|
||||
@select="changeDateSelection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center h-10 self-center order-5 md:order-2 md:justify-self-end"
|
||||
>
|
||||
<span class="mr-2 text-sm whitespace-nowrap">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<ToggleSwitch
|
||||
v-model="businessHoursSelected"
|
||||
@change="onBusinessHoursToggle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isDateRangeSelected" class="order-3 md:order-4">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER') }}
|
||||
</p>
|
||||
<WootDateRangePicker
|
||||
show-range
|
||||
:value="customDateRange"
|
||||
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
|
||||
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
|
||||
class="auto-width"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="notLast7Days" class="multiselect-wrap--small order-4 md:order-5">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
|
||||
</p>
|
||||
<multiselect
|
||||
v-model="currentSelectedGroupByFilter"
|
||||
track-by="id"
|
||||
label="groupBy"
|
||||
<ActiveFilterChip
|
||||
v-if="isGroupByPossible"
|
||||
:id="groupBy?.id"
|
||||
:name="
|
||||
groupByfilterItemsList.find(item => item.id === groupBy?.id)?.name ||
|
||||
$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')
|
||||
"
|
||||
type="groupBy"
|
||||
:options="groupByfilterItemsList"
|
||||
:active-filter-type="showGroupByDropdown ? 'groupBy' : ''"
|
||||
:show-menu="showGroupByDropdown"
|
||||
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
|
||||
:options="groupByFilterItemsList"
|
||||
:allow-empty="false"
|
||||
:show-labels="false"
|
||||
@update:model-value="changeGroupByFilterSelection"
|
||||
:enable-search="false"
|
||||
:show-clear-filter="false"
|
||||
@toggle-dropdown="toggleGroupByDropdown"
|
||||
@close-dropdown="closeGroupByDropdown"
|
||||
@add-filter="onGroupByFilterChange"
|
||||
@remove-filter="() => {}"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="showBusinessHours"
|
||||
class="flex items-center flex-shrink-0 ltr:ml-auto rtl:mr-auto"
|
||||
>
|
||||
<span class="mx-2 text-sm whitespace-nowrap">
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<ToggleSwitch
|
||||
v-model="businessHoursSelected"
|
||||
@change="onBusinessHoursToggle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+185
-139
@@ -1,155 +1,201 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
buildFilterList,
|
||||
getActiveFilter,
|
||||
getFilterType,
|
||||
} from './helpers/SLAFilterHelpers';
|
||||
import {
|
||||
parseFilterURLParams,
|
||||
generateCompleteURLParams,
|
||||
} from '../../helpers/reportFilterHelper';
|
||||
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
|
||||
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterButton,
|
||||
ActiveFilterChip,
|
||||
AddFilterChip,
|
||||
},
|
||||
emits: ['filterChange'],
|
||||
data() {
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const showDropdownMenu = ref(false);
|
||||
const showSubDropdownMenu = ref(false);
|
||||
const activeFilterType = ref('');
|
||||
const appliedFilters = ref({
|
||||
assigned_agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label_list: null,
|
||||
});
|
||||
|
||||
const agents = computed(() => store.getters['agents/getAgents']);
|
||||
const inboxes = computed(() => store.getters['inboxes/getInboxes']);
|
||||
const teams = computed(() => store.getters['teams/getTeams']);
|
||||
const labels = computed(() => store.getters['labels/getLabels']);
|
||||
const sla = computed(() => store.getters['sla/getSLA']);
|
||||
|
||||
const filterListMenuItems = computed(() => {
|
||||
const filterTypes = [
|
||||
{ id: '1', name: t('SLA_REPORTS.DROPDOWN.SLA'), type: 'sla' },
|
||||
{ id: '2', name: t('SLA_REPORTS.DROPDOWN.INBOXES'), type: 'inboxes' },
|
||||
{ id: '3', name: t('SLA_REPORTS.DROPDOWN.AGENTS'), type: 'agents' },
|
||||
{ id: '4', name: t('SLA_REPORTS.DROPDOWN.TEAMS'), type: 'teams' },
|
||||
{ id: '5', name: t('SLA_REPORTS.DROPDOWN.LABELS'), type: 'labels' },
|
||||
];
|
||||
|
||||
const activeFilterKeys = Object.keys(appliedFilters.value).filter(
|
||||
key => appliedFilters.value[key]
|
||||
);
|
||||
const activeFilterTypes = activeFilterKeys.map(key =>
|
||||
getFilterType(key, 'keyToType')
|
||||
);
|
||||
|
||||
const sources = {
|
||||
agents: agents.value,
|
||||
inboxes: inboxes.value,
|
||||
teams: teams.value,
|
||||
labels: labels.value,
|
||||
sla: sla.value,
|
||||
};
|
||||
|
||||
return filterTypes
|
||||
.filter(({ type }) => !activeFilterTypes.includes(type))
|
||||
.map(({ id, name, type }) => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
options: buildFilterList(sources[type], type),
|
||||
}));
|
||||
});
|
||||
|
||||
const activeFilters = computed(() => {
|
||||
const activeKeys = Object.keys(appliedFilters.value).filter(
|
||||
key => appliedFilters.value[key]
|
||||
);
|
||||
|
||||
const sources = {
|
||||
agents: agents.value,
|
||||
inboxes: inboxes.value,
|
||||
teams: teams.value,
|
||||
labels: labels.value,
|
||||
sla: sla.value,
|
||||
};
|
||||
|
||||
return activeKeys.map(key => {
|
||||
const filterType = getFilterType(key, 'keyToType');
|
||||
const item = getActiveFilter(
|
||||
sources[filterType],
|
||||
filterType,
|
||||
appliedFilters.value[key]
|
||||
);
|
||||
return {
|
||||
showDropdownMenu: false,
|
||||
showSubDropdownMenu: false,
|
||||
activeFilterType: '',
|
||||
appliedFilters: {
|
||||
assigned_agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label_list: null,
|
||||
},
|
||||
id: item.id,
|
||||
name: filterType === 'labels' ? item.title : item.name,
|
||||
type: filterType,
|
||||
options: buildFilterList(sources[filterType], filterType),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
agents: 'agents/getAgents',
|
||||
inboxes: 'inboxes/getInboxes',
|
||||
teams: 'teams/getTeams',
|
||||
labels: 'labels/getLabels',
|
||||
sla: 'sla/getSLA',
|
||||
}),
|
||||
filterListMenuItems() {
|
||||
const filterTypes = [
|
||||
{ id: '1', name: this.$t('SLA_REPORTS.DROPDOWN.SLA'), type: 'sla' },
|
||||
{
|
||||
id: '2',
|
||||
name: this.$t('SLA_REPORTS.DROPDOWN.INBOXES'),
|
||||
type: 'inboxes',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: this.$t('SLA_REPORTS.DROPDOWN.AGENTS'),
|
||||
type: 'agents',
|
||||
},
|
||||
{ id: '4', name: this.$t('SLA_REPORTS.DROPDOWN.TEAMS'), type: 'teams' },
|
||||
{
|
||||
id: '5',
|
||||
name: this.$t('SLA_REPORTS.DROPDOWN.LABELS'),
|
||||
type: 'labels',
|
||||
},
|
||||
];
|
||||
// Filter out the active filters from the filter list
|
||||
// We only want to show the filters that are not already applied
|
||||
// In the add filter dropdown
|
||||
const activeFilters = Object.keys(this.appliedFilters).filter(
|
||||
key => this.appliedFilters[key]
|
||||
);
|
||||
const activeFilterTypes = activeFilters.map(key =>
|
||||
getFilterType(key, 'keyToType')
|
||||
);
|
||||
return filterTypes
|
||||
.filter(({ type }) => !activeFilterTypes.includes(type))
|
||||
.map(({ id, name, type }) => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
options: buildFilterList(this[type], type),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
Object.values(appliedFilters.value).some(value => value !== null)
|
||||
);
|
||||
|
||||
const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
|
||||
|
||||
const updateURLParams = () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: route.query.from,
|
||||
to: route.query.to,
|
||||
range: route.query.range,
|
||||
filters: {
|
||||
agent_id: appliedFilters.value.assigned_agent_id,
|
||||
inbox_id: appliedFilters.value.inbox_id,
|
||||
team_id: appliedFilters.value.team_id,
|
||||
sla_policy_id: appliedFilters.value.sla_policy_id,
|
||||
label: appliedFilters.value.label_list,
|
||||
},
|
||||
activeFilters() {
|
||||
// Get the active filters from the applied filters
|
||||
// and return the filter name, type and options
|
||||
const activeKey = Object.keys(this.appliedFilters).filter(
|
||||
key => this.appliedFilters[key]
|
||||
);
|
||||
return activeKey.map(key => {
|
||||
const filterType = getFilterType(key, 'keyToType');
|
||||
const item = getActiveFilter(
|
||||
this[filterType],
|
||||
filterType,
|
||||
this.appliedFilters[key]
|
||||
);
|
||||
return {
|
||||
id: item.id,
|
||||
name: filterType === 'labels' ? item.title : item.name,
|
||||
type: filterType,
|
||||
options: buildFilterList(this[filterType], filterType),
|
||||
};
|
||||
});
|
||||
},
|
||||
hasActiveFilters() {
|
||||
return Object.values(this.appliedFilters).some(value => value !== null);
|
||||
},
|
||||
isAllFilterSelected() {
|
||||
return !this.filterListMenuItems.length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addFilter(item) {
|
||||
const { type, id, name } = item;
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
this.appliedFilters[filterKey] = type === 'labels' ? name : id;
|
||||
this.$emit('filterChange', this.appliedFilters);
|
||||
this.resetDropdown();
|
||||
},
|
||||
removeFilter(type) {
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
this.appliedFilters[filterKey] = null;
|
||||
this.$emit('filterChange', this.appliedFilters);
|
||||
},
|
||||
clearAllFilters() {
|
||||
this.appliedFilters = {
|
||||
assigned_agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label_list: null,
|
||||
};
|
||||
this.$emit('filterChange', this.appliedFilters);
|
||||
this.resetDropdown();
|
||||
},
|
||||
showDropdown() {
|
||||
this.showSubDropdownMenu = false;
|
||||
this.showDropdownMenu = !this.showDropdownMenu;
|
||||
},
|
||||
closeDropdown() {
|
||||
this.showDropdownMenu = false;
|
||||
},
|
||||
openActiveFilterDropdown(filterType) {
|
||||
this.closeDropdown();
|
||||
this.activeFilterType = filterType;
|
||||
this.showSubDropdownMenu = !this.showSubDropdownMenu;
|
||||
},
|
||||
closeActiveFilterDropdown() {
|
||||
this.activeFilterType = '';
|
||||
this.showSubDropdownMenu = false;
|
||||
},
|
||||
resetDropdown() {
|
||||
this.closeDropdown();
|
||||
this.closeActiveFilterDropdown();
|
||||
},
|
||||
},
|
||||
});
|
||||
router.replace({ query: params });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
updateURLParams();
|
||||
emit('filterChange', appliedFilters.value);
|
||||
};
|
||||
|
||||
const showDropdown = () => {
|
||||
showSubDropdownMenu.value = false;
|
||||
showDropdownMenu.value = !showDropdownMenu.value;
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
showDropdownMenu.value = false;
|
||||
};
|
||||
|
||||
const openActiveFilterDropdown = filterType => {
|
||||
closeDropdown();
|
||||
activeFilterType.value = filterType;
|
||||
showSubDropdownMenu.value = !showSubDropdownMenu.value;
|
||||
};
|
||||
|
||||
const closeActiveFilterDropdown = () => {
|
||||
activeFilterType.value = '';
|
||||
showSubDropdownMenu.value = false;
|
||||
};
|
||||
|
||||
const resetDropdown = () => {
|
||||
closeDropdown();
|
||||
closeActiveFilterDropdown();
|
||||
};
|
||||
|
||||
const addFilter = item => {
|
||||
const { type, id, name } = item;
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
appliedFilters.value[filterKey] = type === 'labels' ? name : id;
|
||||
emitChange();
|
||||
resetDropdown();
|
||||
};
|
||||
|
||||
const removeFilter = type => {
|
||||
const filterKey = getFilterType(type, 'typeToKey');
|
||||
appliedFilters.value[filterKey] = null;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
appliedFilters.value = {
|
||||
assigned_agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label_list: null,
|
||||
};
|
||||
emitChange();
|
||||
resetDropdown();
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
const urlFilters = parseFilterURLParams(route.query);
|
||||
appliedFilters.value.assigned_agent_id = urlFilters.agent_id;
|
||||
appliedFilters.value.inbox_id = urlFilters.inbox_id;
|
||||
appliedFilters.value.team_id = urlFilters.team_id;
|
||||
appliedFilters.value.sla_policy_id = urlFilters.sla_policy_id;
|
||||
appliedFilters.value.label_list = urlFilters.label;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
if (hasActiveFilters.value) {
|
||||
emitChange();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+86
-61
@@ -1,73 +1,98 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import SLAFilter from '../SLA/SLAFilter.vue';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import { DATE_RANGE_OPTIONS } from '../../constants';
|
||||
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
|
||||
import { subDays, fromUnixTime } from 'date-fns';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
parseFilterURLParams,
|
||||
} from '../../helpers/reportFilterHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SLAFilter,
|
||||
},
|
||||
emits: ['filterChange'],
|
||||
const emit = defineEmits(['filterChange']);
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
|
||||
selectedGroupByFilter: null,
|
||||
customDateRange: [new Date(), new Date()],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
to() {
|
||||
return getUnixEndOfDay(this.customDateRange[1]);
|
||||
},
|
||||
from() {
|
||||
return getUnixStartOfDay(this.customDateRange[0]);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
businessHoursSelected() {
|
||||
this.emitChange();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setInitialRange();
|
||||
},
|
||||
methods: {
|
||||
setInitialRange() {
|
||||
const { offset } = this.selectedDateRange;
|
||||
const fromDate = subDays(new Date(), offset);
|
||||
const from = getUnixStartOfDay(fromDate);
|
||||
const to = getUnixEndOfDay(new Date());
|
||||
this.$emit('filterChange', {
|
||||
from,
|
||||
to,
|
||||
...this.selectedGroupByFilter,
|
||||
});
|
||||
},
|
||||
emitChange() {
|
||||
const { from, to } = this;
|
||||
this.$emit('filterChange', {
|
||||
from,
|
||||
to,
|
||||
...this.selectedGroupByFilter,
|
||||
});
|
||||
},
|
||||
emitFilterChange(params) {
|
||||
this.selectedGroupByFilter = params;
|
||||
this.emitChange();
|
||||
},
|
||||
onDateRangeChange(value) {
|
||||
this.customDateRange = value;
|
||||
this.emitChange();
|
||||
},
|
||||
},
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize from URL params immediately
|
||||
const urlParams = parseReportURLParams(route.query);
|
||||
const initialDateRange =
|
||||
urlParams.from && urlParams.to
|
||||
? [fromUnixTime(urlParams.from), fromUnixTime(urlParams.to)]
|
||||
: [subDays(new Date(), 6), new Date()];
|
||||
|
||||
const selectedDateRange = ref(urlParams.range || 'last7days');
|
||||
const selectedGroupByFilter = ref(null);
|
||||
const customDateRange = ref(initialDateRange);
|
||||
|
||||
const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
|
||||
const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
|
||||
|
||||
const updateURLParams = () => {
|
||||
const dateParams = generateReportURLParams({
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
range: selectedDateRange.value,
|
||||
});
|
||||
|
||||
const filterParams = parseFilterURLParams(route.query);
|
||||
const params = { ...dateParams };
|
||||
|
||||
if (filterParams.agent_id) params.agent_id = filterParams.agent_id;
|
||||
if (filterParams.inbox_id) params.inbox_id = filterParams.inbox_id;
|
||||
if (filterParams.team_id) params.team_id = filterParams.team_id;
|
||||
if (filterParams.sla_policy_id)
|
||||
params.sla_policy_id = filterParams.sla_policy_id;
|
||||
if (filterParams.label) params.label = filterParams.label;
|
||||
|
||||
router.replace({ query: params });
|
||||
};
|
||||
|
||||
const emitChange = () => {
|
||||
updateURLParams();
|
||||
emit('filterChange', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
...selectedGroupByFilter.value,
|
||||
});
|
||||
};
|
||||
|
||||
const emitFilterChange = params => {
|
||||
selectedGroupByFilter.value = params;
|
||||
emit('filterChange', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
...selectedGroupByFilter.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onDateRangeChange = ([startDate, endDate, rangeType]) => {
|
||||
customDateRange.value = [startDate, endDate];
|
||||
selectedDateRange.value = rangeType;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const setInitialRange = () => {
|
||||
customDateRange.value = [subDays(new Date(), 6), new Date()];
|
||||
emitChange();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!route.query.from || !route.query.to) {
|
||||
setInitialRange();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
|
||||
<woot-date-picker @date-range-changed="onDateRangeChange" />
|
||||
<WootDatePicker
|
||||
v-model:date-range="customDateRange"
|
||||
v-model:range-type="selectedDateRange"
|
||||
@date-range-changed="onDateRangeChange"
|
||||
/>
|
||||
<SLAFilter @filter-change="emitFilterChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+50
-7
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import ReportFilterSelector from './FilterSelector.vue';
|
||||
import OverviewReportFilters from './OverviewReportFilters.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Table from 'dashboard/components/table/Table.vue';
|
||||
import { generateFileName } from 'dashboard/helper/downloadHelper';
|
||||
import {
|
||||
@@ -42,6 +44,16 @@ const businessHours = ref(false);
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SummaryReportLink from './SummaryReportLink.vue';
|
||||
|
||||
const flagMap = {
|
||||
agent: 'isFetchingAgentSummaryReports',
|
||||
inbox: 'isFetchingInboxSummaryReports',
|
||||
team: 'isFetchingTeamSummaryReports',
|
||||
label: 'isFetchingLabelSummaryReports',
|
||||
};
|
||||
|
||||
const uiFlags = useMapGetter('summaryReports/getUIFlags');
|
||||
const isLoading = computed(() => uiFlags.value[flagMap[props.type]] ?? false);
|
||||
|
||||
const rowItems = useMapGetter([props.getterKey]) || [];
|
||||
const reportMetrics = useMapGetter([props.summaryKey]) || [];
|
||||
|
||||
@@ -120,13 +132,26 @@ const tableData = computed(() =>
|
||||
})
|
||||
);
|
||||
|
||||
const fetchAllData = () => {
|
||||
store.dispatch(props.fetchItemsKey);
|
||||
store.dispatch(props.actionKey, {
|
||||
const fetchReportsWithRetry = async () => {
|
||||
const params = {
|
||||
since: from.value,
|
||||
until: to.value,
|
||||
businessHours: businessHours.value,
|
||||
});
|
||||
};
|
||||
try {
|
||||
await store.dispatch(props.actionKey, params);
|
||||
} catch {
|
||||
try {
|
||||
await store.dispatch(props.actionKey, params);
|
||||
} catch {
|
||||
useAlert(t('REPORT.SUMMARY_FETCHING_FAILED'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllData = () => {
|
||||
store.dispatch(props.fetchItemsKey);
|
||||
fetchReportsWithRetry();
|
||||
};
|
||||
|
||||
onMounted(() => fetchAllData());
|
||||
@@ -178,10 +203,28 @@ defineExpose({ downloadReports });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportFilterSelector @filter-change="onFilterChange" />
|
||||
<OverviewReportFilters
|
||||
:disabled="isLoading"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
<div
|
||||
class="flex-1 overflow-auto px-2 py-2 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
class="relative flex-1 overflow-auto px-2 py-2 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
>
|
||||
<Table :table="table" />
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-300 ease-out"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex justify-center pt-[12.5rem] bg-n-solid-1/70 rounded-xl pointer-events-none"
|
||||
>
|
||||
<Spinner :size="32" class="text-n-brand" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+29
-86
@@ -1,31 +1,12 @@
|
||||
<script>
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import ReportFilters from './ReportFilters.vue';
|
||||
import ReportContainer from '../ReportContainer.vue';
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
import { generateFileName } from '../../../../../helper/downloadHelper';
|
||||
import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
|
||||
import ReportHeader from './ReportHeader.vue';
|
||||
|
||||
const GROUP_BY_OPTIONS = {
|
||||
DAY: [{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' }],
|
||||
WEEK: [
|
||||
{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' },
|
||||
{ id: 2, groupByKey: 'REPORT.GROUPING_OPTIONS.WEEK' },
|
||||
],
|
||||
MONTH: [
|
||||
{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' },
|
||||
{ id: 2, groupByKey: 'REPORT.GROUPING_OPTIONS.WEEK' },
|
||||
{ id: 3, groupByKey: 'REPORT.GROUPING_OPTIONS.MONTH' },
|
||||
],
|
||||
YEAR: [
|
||||
{ id: 2, groupByKey: 'REPORT.GROUPING_OPTIONS.WEEK' },
|
||||
{ id: 3, groupByKey: 'REPORT.GROUPING_OPTIONS.MONTH' },
|
||||
{ id: 4, groupByKey: 'REPORT.GROUPING_OPTIONS.YEAR' },
|
||||
],
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ReportHeader,
|
||||
@@ -69,12 +50,19 @@ export default {
|
||||
to: 0,
|
||||
selectedFilter: this.selectedItem,
|
||||
groupBy: GROUP_BY_FILTER[1],
|
||||
groupByfilterItemsList: GROUP_BY_OPTIONS.DAY.map(this.translateOptions),
|
||||
selectedGroupByFilter: null,
|
||||
businessHours: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filterType() {
|
||||
const pluralMap = {
|
||||
agent: 'agents',
|
||||
team: 'teams',
|
||||
inbox: 'inboxes',
|
||||
label: 'labels',
|
||||
};
|
||||
return pluralMap[this.type] || this.type;
|
||||
},
|
||||
filterItemsList() {
|
||||
return this.$store.getters[this.getterKey] || [];
|
||||
},
|
||||
@@ -145,69 +133,29 @@ export default {
|
||||
this.$store.dispatch(dispatchMethods[type], params);
|
||||
}
|
||||
},
|
||||
onDateRangeChange({ from, to, groupBy }) {
|
||||
// do not track filter change on inital load
|
||||
if (this.from !== 0 && this.to !== 0) {
|
||||
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'date',
|
||||
reportType: this.type,
|
||||
});
|
||||
}
|
||||
|
||||
onFilterChange(payload) {
|
||||
const { from, to, businessHours, groupBy } = payload;
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.groupByfilterItemsList = this.fetchFilterItems(groupBy);
|
||||
const filterItems = this.groupByfilterItemsList.filter(
|
||||
item => item.id === this.groupBy.id
|
||||
);
|
||||
if (filterItems.length > 0) {
|
||||
this.selectedGroupByFilter = filterItems[0];
|
||||
this.businessHours = businessHours;
|
||||
|
||||
if (groupBy) {
|
||||
this.groupBy = groupBy;
|
||||
} else {
|
||||
this.selectedGroupByFilter = this.groupByfilterItemsList[0];
|
||||
this.groupBy = GROUP_BY_FILTER[this.selectedGroupByFilter.id];
|
||||
this.groupBy = GROUP_BY_FILTER[1];
|
||||
}
|
||||
this.fetchAllData();
|
||||
},
|
||||
onFilterChange(payload) {
|
||||
if (payload) {
|
||||
this.selectedFilter = payload;
|
||||
this.fetchAllData();
|
||||
}
|
||||
},
|
||||
onGroupByFilterChange(payload) {
|
||||
this.groupBy = GROUP_BY_FILTER[payload.id];
|
||||
this.fetchAllData();
|
||||
|
||||
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'groupBy',
|
||||
filterValue: this.groupBy?.period,
|
||||
reportType: this.type,
|
||||
});
|
||||
},
|
||||
fetchFilterItems(groupBy) {
|
||||
switch (groupBy) {
|
||||
case GROUP_BY_FILTER[2].period:
|
||||
return GROUP_BY_OPTIONS.WEEK.map(this.translateOptions);
|
||||
case GROUP_BY_FILTER[3].period:
|
||||
return GROUP_BY_OPTIONS.MONTH.map(this.translateOptions);
|
||||
case GROUP_BY_FILTER[4].period:
|
||||
return GROUP_BY_OPTIONS.YEAR.map(this.translateOptions);
|
||||
default:
|
||||
return GROUP_BY_OPTIONS.DAY.map(this.translateOptions);
|
||||
// Get filter value directly from filterType key
|
||||
const filterValue = payload[this.filterType];
|
||||
if (filterValue) {
|
||||
this.selectedFilter = Array.isArray(filterValue)
|
||||
? filterValue[0]
|
||||
: filterValue;
|
||||
} else {
|
||||
this.selectedFilter = null;
|
||||
}
|
||||
},
|
||||
translateOptions(opts) {
|
||||
return { id: opts.id, groupBy: this.$t(opts.groupByKey) };
|
||||
},
|
||||
onBusinessHoursToggle(value) {
|
||||
this.businessHours = value;
|
||||
this.fetchAllData();
|
||||
|
||||
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterType: 'businessHours',
|
||||
filterValue: value,
|
||||
reportType: this.type,
|
||||
});
|
||||
this.fetchAllData();
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -222,17 +170,12 @@ export default {
|
||||
@click="downloadReports"
|
||||
/>
|
||||
</ReportHeader>
|
||||
|
||||
<ReportFilters
|
||||
v-if="filterItemsList"
|
||||
:type="type"
|
||||
:filter-items-list="filterItemsList"
|
||||
:group-by-filter-items-list="groupByfilterItemsList"
|
||||
:selected-group-by-filter="selectedGroupByFilter"
|
||||
:current-filter="selectedFilter"
|
||||
@date-range-change="onDateRangeChange"
|
||||
:filter-type="filterType"
|
||||
:selected-item="selectedFilter"
|
||||
@filter-change="onFilterChange"
|
||||
@group-by-filter-change="onGroupByFilterChange"
|
||||
@business-hours-toggle="onBusinessHoursToggle"
|
||||
/>
|
||||
<ReportContainer
|
||||
v-if="filterItemsList.length"
|
||||
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ReportsFiltersAgents from '../../Filters/Agents.vue';
|
||||
|
||||
const mockStore = createStore({
|
||||
modules: {
|
||||
agents: {
|
||||
namespaced: true,
|
||||
state: {
|
||||
agents: [],
|
||||
},
|
||||
getters: {
|
||||
getAgents: state => state.agents,
|
||||
},
|
||||
actions: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
plugins: [mockStore],
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersAgents.vue', () => {
|
||||
it('emits "agents-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersAgents, mountParams);
|
||||
|
||||
const selectedAgents = [
|
||||
{ id: 1, name: 'Agent 1' },
|
||||
{ id: 2, name: 'Agent 2' },
|
||||
];
|
||||
await wrapper.setData({ selectedOptions: selectedAgents });
|
||||
|
||||
await wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('agentsFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('agentsFilterSelection')[0]).toEqual([
|
||||
selectedAgents,
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispatches the "agents/get" action when the component is mounted', () => {
|
||||
const dispatchSpy = vi.spyOn(mockStore, 'dispatch');
|
||||
|
||||
shallowMount(ReportsFiltersAgents, mountParams);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith('agents/get');
|
||||
});
|
||||
});
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ReportsFiltersDateGroupBy from '../../Filters/DateGroupBy.vue';
|
||||
import { GROUP_BY_OPTIONS } from '../../../constants';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersDateGroupBy.vue', () => {
|
||||
it('emits "on-grouping-change" event when changeFilterSelection is called', () => {
|
||||
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
|
||||
|
||||
const selectedFilter = GROUP_BY_OPTIONS.DAY;
|
||||
wrapper.vm.changeFilterSelection(selectedFilter);
|
||||
|
||||
expect(wrapper.emitted('onGroupingChange')).toBeTruthy();
|
||||
expect(wrapper.emitted('onGroupingChange')[0]).toEqual([selectedFilter]);
|
||||
});
|
||||
|
||||
it('updates currentSelectedFilter when selectedOption is changed', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
|
||||
|
||||
const newSelectedOption = GROUP_BY_OPTIONS.MONTH;
|
||||
await wrapper.setProps({ selectedOption: newSelectedOption });
|
||||
|
||||
expect(wrapper.vm.currentSelectedFilter).toEqual({
|
||||
...newSelectedOption,
|
||||
groupBy: newSelectedOption.translationKey,
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes translatedOptions correctly', () => {
|
||||
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
|
||||
|
||||
const expectedOptions = wrapper.vm.validGroupOptions.map(option => ({
|
||||
...option,
|
||||
groupBy: option.translationKey,
|
||||
}));
|
||||
|
||||
expect(wrapper.vm.translatedOptions).toEqual(expectedOptions);
|
||||
});
|
||||
});
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ReportFiltersDateRange from '../../Filters/DateRange.vue';
|
||||
import { DATE_RANGE_OPTIONS } from '../../../constants';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportFiltersDateRange.vue', () => {
|
||||
it('emits "onRangeChange" event when updateRange is called', () => {
|
||||
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
|
||||
|
||||
const selectedRange = DATE_RANGE_OPTIONS.LAST_7_DAYS;
|
||||
wrapper.vm.updateRange(selectedRange);
|
||||
|
||||
expect(wrapper.emitted('onRangeChange')).toBeTruthy();
|
||||
expect(wrapper.emitted('onRangeChange')[0]).toEqual([selectedRange]);
|
||||
});
|
||||
|
||||
it('initializes options correctly', () => {
|
||||
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
|
||||
|
||||
const expectedIds = Object.values(DATE_RANGE_OPTIONS).map(
|
||||
option => option.id
|
||||
);
|
||||
const receivedIds = wrapper.vm.options.map(option => option.id);
|
||||
|
||||
expect(receivedIds).toEqual(expectedIds);
|
||||
});
|
||||
|
||||
it('initializes selectedOption correctly', () => {
|
||||
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
|
||||
const expectedId = Object.values(DATE_RANGE_OPTIONS)[0].id;
|
||||
expect(wrapper.vm.selectedOption.id).toBe(expectedId);
|
||||
});
|
||||
});
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ReportsFiltersInboxes from '../../Filters/Inboxes.vue';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersInboxes.vue', () => {
|
||||
let store;
|
||||
let inboxesModule;
|
||||
|
||||
beforeEach(() => {
|
||||
inboxesModule = {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getInboxes: () => () => [
|
||||
{ id: 1, name: 'Inbox 1' },
|
||||
{ id: 2, name: 'Inbox 2' },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
store = createStore({
|
||||
modules: {
|
||||
inboxes: inboxesModule,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches "inboxes/get" action when component is mounted', () => {
|
||||
shallowMount(ReportsFiltersInboxes, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams.global,
|
||||
},
|
||||
});
|
||||
expect(inboxesModule.actions.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "inbox-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersInboxes, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams.global,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedInbox = { id: 1, name: 'Inbox 1' };
|
||||
await wrapper.setData({ selectedOption: selectedInbox });
|
||||
|
||||
await wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('inboxFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('inboxFilterSelection')[0]).toEqual([selectedInbox]);
|
||||
});
|
||||
});
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ReportsFiltersLabels from '../../Filters/Labels.vue';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersLabels.vue', () => {
|
||||
let store;
|
||||
let labelsModule;
|
||||
|
||||
beforeEach(() => {
|
||||
labelsModule = {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getLabels: () => () => [
|
||||
{ id: 1, title: 'Label 1', color: 'red' },
|
||||
{ id: 2, title: 'Label 2', color: 'blue' },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
store = createStore({
|
||||
modules: {
|
||||
labels: labelsModule,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches "labels/get" action when component is mounted', () => {
|
||||
shallowMount(ReportsFiltersLabels, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams.global,
|
||||
},
|
||||
});
|
||||
expect(labelsModule.actions.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "labels-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersLabels, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams.global,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedLabel = { id: 1, title: 'Label 1', color: 'red' };
|
||||
await wrapper.setData({ selectedOption: selectedLabel });
|
||||
|
||||
await wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('labelsFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('labelsFilterSelection')[0]).toEqual([
|
||||
selectedLabel,
|
||||
]);
|
||||
});
|
||||
});
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ReportFiltersRatings from '../../Filters/Ratings.vue';
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportFiltersRatings.vue', () => {
|
||||
it('emits "rating-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportFiltersRatings, {
|
||||
...mountParams,
|
||||
});
|
||||
|
||||
const selectedRating = { value: 1, label: 'Rating 1' };
|
||||
await wrapper.setData({ selectedOption: selectedRating });
|
||||
|
||||
await wrapper.vm.handleInput(selectedRating);
|
||||
|
||||
expect(wrapper.emitted('ratingFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('ratingFilterSelection')[0]).toEqual([
|
||||
selectedRating,
|
||||
]);
|
||||
});
|
||||
|
||||
it('initializes options correctly', () => {
|
||||
const wrapper = shallowMount(ReportFiltersRatings, {
|
||||
...mountParams,
|
||||
});
|
||||
|
||||
const expectedOptions = CSAT_RATINGS.map(option => ({
|
||||
...option,
|
||||
label: option.translationKey,
|
||||
}));
|
||||
|
||||
expect(wrapper.vm.options).toEqual(expectedOptions);
|
||||
});
|
||||
});
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import ReportsFiltersTeams from '../../Filters/Teams.vue';
|
||||
|
||||
const mountParams = {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['multiselect'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportsFiltersTeams.vue', () => {
|
||||
let store;
|
||||
let teamsModule;
|
||||
|
||||
beforeEach(() => {
|
||||
teamsModule = {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getTeams: () => () => [
|
||||
{ id: 1, name: 'Team 1' },
|
||||
{ id: 2, name: 'Team 2' },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
store = createStore({
|
||||
modules: {
|
||||
teams: teamsModule,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches "teams/get" action when component is mounted', () => {
|
||||
shallowMount(ReportsFiltersTeams, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams,
|
||||
},
|
||||
});
|
||||
expect(teamsModule.actions.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "team-filter-selection" event when handleInput is called', async () => {
|
||||
const wrapper = shallowMount(ReportsFiltersTeams, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
...mountParams,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.setData({ selectedOption: { id: 1, name: 'Team 1' } });
|
||||
await wrapper.vm.handleInput();
|
||||
|
||||
expect(wrapper.emitted('teamFilterSelection')).toBeTruthy();
|
||||
expect(wrapper.emitted('teamFilterSelection')[0]).toEqual([
|
||||
{ id: 1, name: 'Team 1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
export const generateReportURLParams = ({
|
||||
from,
|
||||
to,
|
||||
businessHours,
|
||||
groupBy,
|
||||
range,
|
||||
}) => {
|
||||
const params = {};
|
||||
|
||||
// Always include from/to dates
|
||||
if (from) params.from = from;
|
||||
if (to) params.to = to;
|
||||
|
||||
if (businessHours) params.business_hours = 'true';
|
||||
if (groupBy) params.group_by = groupBy;
|
||||
|
||||
// Include range type (last7days, last3months, custom, etc.)
|
||||
if (range) params.range = range;
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
export const parseReportURLParams = query => {
|
||||
const { from, to, business_hours, group_by, range } = query;
|
||||
|
||||
return {
|
||||
from: from ? Number(from) : null,
|
||||
to: to ? Number(to) : null,
|
||||
businessHours: business_hours === 'true',
|
||||
groupBy: group_by ? Number(group_by) : null,
|
||||
range: range || null,
|
||||
};
|
||||
};
|
||||
|
||||
// Parse filter params from URL (agent_id, inbox_id, team_id, sla_policy_id, label, rating)
|
||||
export const parseFilterURLParams = query => {
|
||||
return {
|
||||
agent_id: query.agent_id ? Number(query.agent_id) : null,
|
||||
inbox_id: query.inbox_id ? Number(query.inbox_id) : null,
|
||||
team_id: query.team_id ? Number(query.team_id) : null,
|
||||
sla_policy_id: query.sla_policy_id ? Number(query.sla_policy_id) : null,
|
||||
label: query.label || null,
|
||||
rating: query.rating ? Number(query.rating) : null,
|
||||
};
|
||||
};
|
||||
|
||||
// Generate filter URL params (only include non-null values)
|
||||
export const generateFilterURLParams = filters => {
|
||||
const params = {};
|
||||
if (filters.agent_id) params.agent_id = filters.agent_id;
|
||||
if (filters.inbox_id) params.inbox_id = filters.inbox_id;
|
||||
if (filters.team_id) params.team_id = filters.team_id;
|
||||
if (filters.sla_policy_id) params.sla_policy_id = filters.sla_policy_id;
|
||||
if (filters.label) params.label = filters.label;
|
||||
if (filters.rating) params.rating = filters.rating;
|
||||
return params;
|
||||
};
|
||||
|
||||
// Merge date range and filter params for complete URL
|
||||
export const generateCompleteURLParams = ({
|
||||
from,
|
||||
to,
|
||||
range,
|
||||
filters = {},
|
||||
}) => {
|
||||
const dateParams = generateReportURLParams({ from, to, range });
|
||||
const filterParams = generateFilterURLParams(filters);
|
||||
return { ...dateParams, ...filterParams };
|
||||
};
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
import {
|
||||
generateReportURLParams,
|
||||
parseReportURLParams,
|
||||
parseFilterURLParams,
|
||||
generateFilterURLParams,
|
||||
generateCompleteURLParams,
|
||||
} from './reportFilterHelper';
|
||||
|
||||
describe('reportFilterHelper', () => {
|
||||
describe('generateReportURLParams', () => {
|
||||
it('generates URL params with from and to dates', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes business hours when true', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: true,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
business_hours: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes business hours when false', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: false,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes group by parameter', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
groupBy: 3,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
group_by: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes range type', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
});
|
||||
});
|
||||
|
||||
it('generates complete URL params with all options', () => {
|
||||
const params = generateReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: true,
|
||||
groupBy: 3,
|
||||
range: 'lastYear',
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
business_hours: 'true',
|
||||
group_by: 3,
|
||||
range: 'lastYear',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseReportURLParams', () => {
|
||||
it('parses from and to dates as numbers', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: false,
|
||||
groupBy: null,
|
||||
range: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses business hours as boolean', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
business_hours: 'true',
|
||||
});
|
||||
|
||||
expect(result.businessHours).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for business hours when not "true"', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
business_hours: 'false',
|
||||
});
|
||||
|
||||
expect(result.businessHours).toBe(false);
|
||||
});
|
||||
|
||||
it('parses group by as number', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
group_by: '3',
|
||||
});
|
||||
|
||||
expect(result.groupBy).toBe(3);
|
||||
});
|
||||
|
||||
it('parses range type', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
range: 'last7days',
|
||||
});
|
||||
|
||||
expect(result.range).toBe('last7days');
|
||||
});
|
||||
|
||||
it('returns null for missing parameters', () => {
|
||||
const result = parseReportURLParams({});
|
||||
|
||||
expect(result).toEqual({
|
||||
from: null,
|
||||
to: null,
|
||||
businessHours: false,
|
||||
groupBy: null,
|
||||
range: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses complete URL params with all options', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: '1738607400',
|
||||
to: '1770229799',
|
||||
business_hours: 'true',
|
||||
group_by: '3',
|
||||
range: 'lastYear',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: true,
|
||||
groupBy: 3,
|
||||
range: 'lastYear',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles numeric values correctly', () => {
|
||||
const result = parseReportURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
group_by: 3,
|
||||
});
|
||||
|
||||
expect(result.from).toBe(1738607400);
|
||||
expect(result.to).toBe(1770229799);
|
||||
expect(result.groupBy).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip conversion', () => {
|
||||
it('maintains data integrity through generate and parse cycle', () => {
|
||||
const original = {
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
businessHours: true,
|
||||
groupBy: 3,
|
||||
range: 'lastYear',
|
||||
};
|
||||
|
||||
const urlParams = generateReportURLParams(original);
|
||||
const parsed = parseReportURLParams(urlParams);
|
||||
|
||||
expect(parsed.from).toBe(original.from);
|
||||
expect(parsed.to).toBe(original.to);
|
||||
expect(parsed.businessHours).toBe(original.businessHours);
|
||||
expect(parsed.groupBy).toBe(original.groupBy);
|
||||
expect(parsed.range).toBe(original.range);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFilterURLParams', () => {
|
||||
it('parses all filter params as numbers', () => {
|
||||
const result = parseFilterURLParams({
|
||||
agent_id: '123',
|
||||
inbox_id: '456',
|
||||
team_id: '789',
|
||||
sla_policy_id: '101',
|
||||
rating: '4',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
agent_id: 123,
|
||||
inbox_id: 456,
|
||||
team_id: 789,
|
||||
sla_policy_id: 101,
|
||||
label: null,
|
||||
rating: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses label as string', () => {
|
||||
const result = parseFilterURLParams({
|
||||
label: 'bug',
|
||||
});
|
||||
|
||||
expect(result.label).toBe('bug');
|
||||
});
|
||||
|
||||
it('returns null for missing parameters', () => {
|
||||
const result = parseFilterURLParams({});
|
||||
|
||||
expect(result).toEqual({
|
||||
agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
sla_policy_id: null,
|
||||
label: null,
|
||||
rating: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateFilterURLParams', () => {
|
||||
it('includes only non-null filter values', () => {
|
||||
const params = generateFilterURLParams({
|
||||
agent_id: 123,
|
||||
inbox_id: null,
|
||||
team_id: 456,
|
||||
rating: null,
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
agent_id: 123,
|
||||
team_id: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes label when present', () => {
|
||||
const params = generateFilterURLParams({
|
||||
label: 'bug',
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
label: 'bug',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty object when all values are null', () => {
|
||||
const params = generateFilterURLParams({
|
||||
agent_id: null,
|
||||
inbox_id: null,
|
||||
team_id: null,
|
||||
});
|
||||
|
||||
expect(params).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCompleteURLParams', () => {
|
||||
it('merges date and filter params', () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
filters: {
|
||||
agent_id: 123,
|
||||
inbox_id: 456,
|
||||
},
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
agent_id: 123,
|
||||
inbox_id: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty filters', () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
filters: {},
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
range: 'last7days',
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes null filter values', () => {
|
||||
const params = generateCompleteURLParams({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
filters: {
|
||||
agent_id: 123,
|
||||
inbox_id: null,
|
||||
team_id: 456,
|
||||
},
|
||||
});
|
||||
|
||||
expect(params).toEqual({
|
||||
from: 1738607400,
|
||||
to: 1770229799,
|
||||
agent_id: 123,
|
||||
team_id: 456,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -96,7 +96,7 @@ const actions = {
|
||||
data: payload,
|
||||
});
|
||||
if (!payload.length) {
|
||||
commit(types.SET_ALL_MESSAGES_LOADED);
|
||||
commit(types.SET_ALL_MESSAGES_LOADED, data.conversationId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
@@ -191,7 +191,7 @@ const actions = {
|
||||
|
||||
async setActiveChat({ commit, dispatch }, { data, after }) {
|
||||
commit(types.SET_CURRENT_CHAT_WINDOW, data);
|
||||
commit(types.CLEAR_ALL_MESSAGES_LOADED);
|
||||
commit(types.CLEAR_ALL_MESSAGES_LOADED, data.id);
|
||||
if (data.dataFetched === undefined) {
|
||||
try {
|
||||
await dispatch('fetchPreviousMessages', {
|
||||
@@ -199,7 +199,7 @@ const actions = {
|
||||
before: data.messages[0].id,
|
||||
conversationId: data.id,
|
||||
});
|
||||
data.dataFetched = true;
|
||||
commit(types.SET_CHAT_DATA_FETCHED, data.id);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
@@ -212,14 +212,17 @@ const actions = {
|
||||
conversationId,
|
||||
agentId,
|
||||
});
|
||||
dispatch('setCurrentChatAssignee', response.data);
|
||||
dispatch('setCurrentChatAssignee', {
|
||||
conversationId,
|
||||
assignee: response.data,
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentChatAssignee({ commit }, assignee) {
|
||||
commit(types.ASSIGN_AGENT, assignee);
|
||||
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
|
||||
commit(types.ASSIGN_AGENT, { conversationId, assignee });
|
||||
},
|
||||
|
||||
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
|
||||
|
||||
@@ -63,14 +63,18 @@ export const mutations = {
|
||||
_state.allConversations = [];
|
||||
_state.selectedChatId = null;
|
||||
},
|
||||
[types.SET_ALL_MESSAGES_LOADED](_state) {
|
||||
const [chat] = getSelectedChatConversation(_state);
|
||||
chat.allMessagesLoaded = true;
|
||||
[types.SET_ALL_MESSAGES_LOADED](_state, conversationId) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
chat.allMessagesLoaded = true;
|
||||
}
|
||||
},
|
||||
|
||||
[types.CLEAR_ALL_MESSAGES_LOADED](_state) {
|
||||
const [chat] = getSelectedChatConversation(_state);
|
||||
chat.allMessagesLoaded = false;
|
||||
[types.CLEAR_ALL_MESSAGES_LOADED](_state, conversationId) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
chat.allMessagesLoaded = false;
|
||||
}
|
||||
},
|
||||
[types.CLEAR_CURRENT_CHAT_WINDOW](_state) {
|
||||
_state.selectedChatId = null;
|
||||
@@ -91,15 +95,24 @@ export const mutations = {
|
||||
chat.messages = data;
|
||||
},
|
||||
|
||||
[types.SET_CHAT_DATA_FETCHED](_state, conversationId) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
chat.dataFetched = true;
|
||||
}
|
||||
},
|
||||
|
||||
[types.SET_CURRENT_CHAT_WINDOW](_state, activeChat) {
|
||||
if (activeChat) {
|
||||
_state.selectedChatId = activeChat.id;
|
||||
}
|
||||
},
|
||||
|
||||
[types.ASSIGN_AGENT](_state, assignee) {
|
||||
const [chat] = getSelectedChatConversation(_state);
|
||||
chat.meta.assignee = assignee;
|
||||
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
chat.meta.assignee = assignee;
|
||||
}
|
||||
},
|
||||
|
||||
[types.ASSIGN_TEAM](_state, { team, conversationId }) {
|
||||
@@ -274,8 +287,10 @@ export const mutations = {
|
||||
|
||||
// Update assignee on action cable message
|
||||
[types.UPDATE_ASSIGNEE](_state, payload) {
|
||||
const [chat] = _state.allConversations.filter(c => c.id === payload.id);
|
||||
chat.meta.assignee = payload.assignee;
|
||||
const chat = getConversationById(_state)(payload.id);
|
||||
if (chat) {
|
||||
chat.meta.assignee = payload.assignee;
|
||||
}
|
||||
},
|
||||
|
||||
[types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) {
|
||||
|
||||
@@ -27,7 +27,7 @@ export const getters = {
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
},
|
||||
getLabelById: _state => id => {
|
||||
return _state.records.find(record => record.id === Number(id));
|
||||
return _state.records.find(record => record.id === Number(id)) || {};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -355,22 +355,26 @@ describe('#actions', () => {
|
||||
axios.post.mockResolvedValue({
|
||||
data: { id: 1, name: 'User' },
|
||||
});
|
||||
await actions.assignAgent({ commit }, { conversationId: 1, agentId: 1 });
|
||||
expect(commit).toHaveBeenCalledTimes(0);
|
||||
expect(commit.mock.calls).toEqual([]);
|
||||
await actions.assignAgent(
|
||||
{ dispatch },
|
||||
{ conversationId: 1, agentId: 1 }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
|
||||
conversationId: 1,
|
||||
assignee: { id: 1, name: 'User' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setCurrentChatAssignee', () => {
|
||||
it('sends correct mutations if assignment is successful', async () => {
|
||||
axios.post.mockResolvedValue({
|
||||
data: { id: 1, name: 'User' },
|
||||
});
|
||||
await actions.setCurrentChatAssignee({ commit }, { id: 1, name: 'User' });
|
||||
const payload = {
|
||||
conversationId: 1,
|
||||
assignee: { id: 1, name: 'User' },
|
||||
};
|
||||
await actions.setCurrentChatAssignee({ commit }, payload);
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['ASSIGN_AGENT', { id: 1, name: 'User' }],
|
||||
]);
|
||||
expect(commit.mock.calls).toEqual([['ASSIGN_AGENT', payload]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -716,6 +720,64 @@ describe('#addMentions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setActiveChat', () => {
|
||||
it('should commit SET_CHAT_DATA_FETCHED with conversation ID after fetch', async () => {
|
||||
const localCommit = vi.fn();
|
||||
const localDispatch = vi.fn().mockResolvedValue();
|
||||
const data = { id: 42, messages: [{ id: 100 }] };
|
||||
|
||||
await actions.setActiveChat(
|
||||
{ commit: localCommit, dispatch: localDispatch },
|
||||
{ data, after: 99 }
|
||||
);
|
||||
|
||||
expect(localCommit.mock.calls).toEqual([
|
||||
[types.SET_CURRENT_CHAT_WINDOW, data],
|
||||
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
|
||||
[types.SET_CHAT_DATA_FETCHED, 42],
|
||||
]);
|
||||
expect(localDispatch).toHaveBeenCalledWith('fetchPreviousMessages', {
|
||||
after: 99,
|
||||
before: 100,
|
||||
conversationId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not dispatch fetchPreviousMessages if dataFetched is already set', async () => {
|
||||
const localCommit = vi.fn();
|
||||
const localDispatch = vi.fn();
|
||||
const data = { id: 42, messages: [{ id: 100 }], dataFetched: true };
|
||||
|
||||
await actions.setActiveChat(
|
||||
{ commit: localCommit, dispatch: localDispatch },
|
||||
{ data }
|
||||
);
|
||||
|
||||
expect(localCommit.mock.calls).toEqual([
|
||||
[types.SET_CURRENT_CHAT_WINDOW, data],
|
||||
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
|
||||
]);
|
||||
expect(localDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should commit SET_CHAT_DATA_FETCHED by ID, not mutate the data object directly (race condition fix)', async () => {
|
||||
const localCommit = vi.fn();
|
||||
const localDispatch = vi.fn().mockResolvedValue();
|
||||
const data = { id: 42, messages: [{ id: 100 }] };
|
||||
|
||||
await actions.setActiveChat(
|
||||
{ commit: localCommit, dispatch: localDispatch },
|
||||
{ data }
|
||||
);
|
||||
|
||||
// The action must NOT set dataFetched on the data object directly
|
||||
expect(data.dataFetched).toBeUndefined();
|
||||
|
||||
// Instead it commits a mutation that finds the conversation by ID in the store
|
||||
expect(localCommit).toHaveBeenCalledWith(types.SET_CHAT_DATA_FETCHED, 42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getInboxCaptainAssistantById', () => {
|
||||
it('fetches inbox assistant by id', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
|
||||
@@ -570,25 +570,84 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_ALL_MESSAGES_LOADED', () => {
|
||||
it('should set allMessagesLoaded to true on selected chat', () => {
|
||||
describe('#SET_CHAT_DATA_FETCHED', () => {
|
||||
it('should set dataFetched to true on the conversation by ID', () => {
|
||||
const state = {
|
||||
allConversations: [{ id: 1, allMessagesLoaded: false }],
|
||||
allConversations: [{ id: 1 }, { id: 2 }],
|
||||
};
|
||||
mutations[types.SET_CHAT_DATA_FETCHED](state, 1);
|
||||
expect(state.allConversations[0].dataFetched).toBe(true);
|
||||
expect(state.allConversations[1].dataFetched).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should do nothing if conversation is not found', () => {
|
||||
const state = { allConversations: [{ id: 1 }] };
|
||||
mutations[types.SET_CHAT_DATA_FETCHED](state, 999);
|
||||
expect(state.allConversations[0].dataFetched).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should survive the race: SET_ALL_CONVERSATION replaces the object, then SET_CHAT_DATA_FETCHED still works', () => {
|
||||
// 1. Initial state: conversation exists with dataFetched undefined
|
||||
const state = {
|
||||
allConversations: [{ id: 1, messages: [{ id: 'm1' }] }],
|
||||
selectedChatId: 1,
|
||||
};
|
||||
mutations[types.SET_ALL_MESSAGES_LOADED](state);
|
||||
const originalRef = state.allConversations[0];
|
||||
|
||||
// 2. Simulate SET_ALL_CONVERSATION replacing the object (WebSocket/polling)
|
||||
// This copies dataFetched from the old object (still undefined)
|
||||
mutations[types.SET_ALL_CONVERSATION](state, [
|
||||
{ id: 1, name: 'refreshed', messages: [{ id: 'm2' }] },
|
||||
]);
|
||||
|
||||
// The store now holds a NEW object, old reference is detached
|
||||
const newRef = state.allConversations[0];
|
||||
expect(newRef).not.toBe(originalRef);
|
||||
expect(newRef.dataFetched).toBeUndefined();
|
||||
|
||||
// 3. SET_CHAT_DATA_FETCHED finds by ID — works on the current store object
|
||||
mutations[types.SET_CHAT_DATA_FETCHED](state, 1);
|
||||
expect(state.allConversations[0].dataFetched).toBe(true);
|
||||
|
||||
// Old detached reference is unaffected
|
||||
expect(originalRef.dataFetched).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_ALL_MESSAGES_LOADED', () => {
|
||||
it('should set allMessagesLoaded to true on the conversation by ID', () => {
|
||||
const state = {
|
||||
allConversations: [{ id: 1, allMessagesLoaded: false }, { id: 2 }],
|
||||
};
|
||||
mutations[types.SET_ALL_MESSAGES_LOADED](state, 1);
|
||||
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
|
||||
expect(state.allConversations[1].allMessagesLoaded).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should do nothing if conversation is not found', () => {
|
||||
const state = { allConversations: [{ id: 1 }] };
|
||||
mutations[types.SET_ALL_MESSAGES_LOADED](state, 999);
|
||||
expect(state.allConversations[0].allMessagesLoaded).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#CLEAR_ALL_MESSAGES_LOADED', () => {
|
||||
it('should set allMessagesLoaded to false on selected chat', () => {
|
||||
it('should set allMessagesLoaded to false on the conversation by ID', () => {
|
||||
const state = {
|
||||
allConversations: [{ id: 1, allMessagesLoaded: true }],
|
||||
selectedChatId: 1,
|
||||
allConversations: [
|
||||
{ id: 1, allMessagesLoaded: true },
|
||||
{ id: 2, allMessagesLoaded: true },
|
||||
],
|
||||
};
|
||||
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state);
|
||||
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 1);
|
||||
expect(state.allConversations[0].allMessagesLoaded).toBe(false);
|
||||
expect(state.allConversations[1].allMessagesLoaded).toBe(true);
|
||||
});
|
||||
|
||||
it('should do nothing if conversation is not found', () => {
|
||||
const state = { allConversations: [{ id: 1, allMessagesLoaded: true }] };
|
||||
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 999);
|
||||
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -640,15 +699,22 @@ describe('#mutations', () => {
|
||||
});
|
||||
|
||||
describe('#ASSIGN_AGENT', () => {
|
||||
it('should assign agent to selected conversation', () => {
|
||||
it('should assign agent to the correct conversation by ID', () => {
|
||||
const assignee = { id: 1, name: 'Agent' };
|
||||
const state = {
|
||||
allConversations: [{ id: 1, meta: {} }],
|
||||
selectedChatId: 1,
|
||||
allConversations: [
|
||||
{ id: 1, meta: {} },
|
||||
{ id: 2, meta: {} },
|
||||
],
|
||||
selectedChatId: 2,
|
||||
};
|
||||
|
||||
mutations[types.ASSIGN_AGENT](state, assignee);
|
||||
mutations[types.ASSIGN_AGENT](state, {
|
||||
conversationId: 1,
|
||||
assignee,
|
||||
});
|
||||
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
|
||||
expect(state.allConversations[1].meta.assignee).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -797,6 +863,34 @@ describe('#mutations', () => {
|
||||
mutations[types.UPDATE_CONVERSATION](state, conversation);
|
||||
expect(state.allConversations[0].status).toEqual('resolved');
|
||||
});
|
||||
|
||||
it('should preserve dataFetched and allMessagesLoaded during update', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
status: 'open',
|
||||
updated_at: 100,
|
||||
messages: [{ id: 'msg1' }],
|
||||
dataFetched: true,
|
||||
allMessagesLoaded: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const conversation = {
|
||||
id: 1,
|
||||
status: 'resolved',
|
||||
updated_at: 200,
|
||||
messages: [{ id: 'msg2' }],
|
||||
};
|
||||
|
||||
mutations[types.UPDATE_CONVERSATION](state, conversation);
|
||||
expect(state.allConversations[0].status).toEqual('resolved');
|
||||
expect(state.allConversations[0].dataFetched).toBe(true);
|
||||
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
|
||||
expect(state.allConversations[0].messages).toEqual([{ id: 'msg1' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#UPDATE_CONVERSATION_CONTACT', () => {
|
||||
|
||||
@@ -141,12 +141,14 @@ describe('Summary Reports Store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
it('should reset uiFlags and rethrow error on failure', async () => {
|
||||
SummaryReportsAPI.getInboxReports.mockRejectedValue(
|
||||
new Error('API Error')
|
||||
);
|
||||
|
||||
await store.actions.fetchInboxSummaryReports({ commit }, {});
|
||||
await expect(
|
||||
store.actions.fetchInboxSummaryReports({ commit }, {})
|
||||
).rejects.toThrow('API Error');
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setUIFlags', {
|
||||
isFetchingInboxSummaryReports: false,
|
||||
|
||||
@@ -28,15 +28,17 @@ async function fetchSummaryReports(type, params, { commit }) {
|
||||
const config = typeMap[type];
|
||||
if (!config) return;
|
||||
|
||||
let error = null;
|
||||
try {
|
||||
commit('setUIFlags', { [config.flagKey]: true });
|
||||
const response = await SummaryReportsAPI[config.apiMethod](params);
|
||||
commit(config.mutationKey, camelcaseKeys(response.data, { deep: true }));
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
} catch (e) {
|
||||
error = e;
|
||||
} finally {
|
||||
commit('setUIFlags', { [config.flagKey]: false });
|
||||
}
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export const initialState = {
|
||||
|
||||
@@ -64,6 +64,7 @@ export default {
|
||||
|
||||
SET_CONTEXT_MENU_CHAT_ID: 'SET_CONTEXT_MENU_CHAT_ID',
|
||||
|
||||
SET_CHAT_DATA_FETCHED: 'SET_CHAT_DATA_FETCHED',
|
||||
SET_CHAT_LIST_FILTERS: 'SET_CHAT_LIST_FILTERS',
|
||||
UPDATE_CHAT_LIST_FILTERS: 'UPDATE_CHAT_LIST_FILTERS',
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
|
||||
content_type: avatar_file.content_type
|
||||
)
|
||||
|
||||
rescue Down::NotFound, Down::Error => e
|
||||
rescue Down::NotFound
|
||||
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
|
||||
rescue Down::Error => e
|
||||
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
|
||||
ensure
|
||||
update_avatar_sync_attributes(avatarable, avatar_url)
|
||||
|
||||
@@ -13,7 +13,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
end
|
||||
rescue *ExceptionList::IMAP_EXCEPTIONS => e
|
||||
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}"
|
||||
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e
|
||||
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError,
|
||||
Net::IMAP::ResponseParseError, Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError => e
|
||||
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}"
|
||||
rescue LockAcquisitionError
|
||||
Rails.logger.error "Lock failed for #{channel.inbox.id}"
|
||||
|
||||
@@ -40,6 +40,7 @@ class Account < ApplicationRecord
|
||||
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
|
||||
'audio_transcriptions': { 'type': %w[boolean null] },
|
||||
'auto_resolve_label': { 'type': %w[string null] },
|
||||
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
|
||||
'conversation_required_attributes': {
|
||||
'type': %w[array null],
|
||||
'items': { 'type': 'string' }
|
||||
@@ -88,6 +89,7 @@ class Account < ApplicationRecord
|
||||
|
||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
||||
store_accessor :settings, :captain_models, :captain_features
|
||||
store_accessor :settings, :keep_pending_on_bot_failure
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
|
||||
@@ -12,6 +12,10 @@ module AccountEmailRateLimitable
|
||||
Redis::Alfred.get(email_count_cache_key).to_i
|
||||
end
|
||||
|
||||
def email_transcript_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
def within_email_rate_limit?
|
||||
return true if emails_sent_today < email_rate_limit
|
||||
|
||||
|
||||
@@ -318,7 +318,9 @@ class Message < ApplicationRecord
|
||||
end
|
||||
|
||||
def update_contact_activity
|
||||
sender.update(last_activity_at: DateTime.now) if sender.is_a?(Contact)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
sender.update_columns(last_activity_at: DateTime.now) if sender.is_a?(Contact)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def update_waiting_since
|
||||
|
||||
@@ -130,11 +130,15 @@ class MailPresenter < SimpleDelegator
|
||||
end
|
||||
|
||||
def sender_name
|
||||
Mail::Address.new((@mail[:reply_to] || @mail[:from]).value).name
|
||||
parse_mail_address((@mail[:reply_to] || @mail[:from]).value)&.name
|
||||
end
|
||||
|
||||
def original_sender
|
||||
from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
|
||||
[
|
||||
@mail[:reply_to]&.value,
|
||||
@mail['X-Original-Sender']&.value,
|
||||
@mail[:from]&.value
|
||||
].filter_map { |email| parse_mail_address(email)&.address }.first
|
||||
end
|
||||
|
||||
def headers_data
|
||||
@@ -147,10 +151,6 @@ class MailPresenter < SimpleDelegator
|
||||
headers.presence
|
||||
end
|
||||
|
||||
def from_email_address(email)
|
||||
Mail::Address.new(email).address
|
||||
end
|
||||
|
||||
def email_forwarded_for
|
||||
@mail['X-Forwarded-For'].try(:value)
|
||||
end
|
||||
@@ -175,11 +175,20 @@ class MailPresenter < SimpleDelegator
|
||||
|
||||
def notification_email_from_chatwoot?
|
||||
# notification emails are send via mailer sender email address. so it should match
|
||||
original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
|
||||
configured_sender = Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
|
||||
original_sender.to_s.casecmp?(configured_sender)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_mail_address(email)
|
||||
return if email.blank?
|
||||
|
||||
Mail::Address.new(email)
|
||||
rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError
|
||||
nil
|
||||
end
|
||||
|
||||
def auto_submitted?
|
||||
@mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no'
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class AccountDeletionService
|
||||
SOFT_DELETE_EMAIL_DOMAIN = '@chatwoot-deleted.invalid'.freeze
|
||||
|
||||
attr_reader :account, :soft_deleted_users
|
||||
|
||||
def initialize(account:)
|
||||
@@ -25,15 +27,11 @@ class AccountDeletionService
|
||||
|
||||
def soft_delete_orphaned_users
|
||||
account.users.each do |user|
|
||||
# Find all account_users for this user excluding the current account
|
||||
other_accounts = user.account_users.where.not(account_id: account.id).count
|
||||
# Skip users who are still associated with another account.
|
||||
next if user.account_users.where.not(account_id: account.id).exists?
|
||||
|
||||
# If user has no other accounts, soft delete them
|
||||
next unless other_accounts.zero?
|
||||
|
||||
# Soft delete user by appending -deleted.com to email
|
||||
original_email = user.email
|
||||
user.email = "#{original_email}-deleted.com"
|
||||
user.email = soft_deleted_email_for(user)
|
||||
user.skip_reconfirmation!
|
||||
user.save!
|
||||
|
||||
@@ -47,4 +45,8 @@ class AccountDeletionService
|
||||
Rails.logger.info("Soft deleted user #{user.id} with email #{original_email}")
|
||||
end
|
||||
end
|
||||
|
||||
def soft_deleted_email_for(user)
|
||||
"#{user.id}#{SOFT_DELETE_EMAIL_DOMAIN}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -75,11 +75,16 @@ class ActionService
|
||||
end
|
||||
|
||||
def send_email_transcript(emails)
|
||||
return unless @account.email_transcript_enabled?
|
||||
|
||||
emails = emails[0].gsub(/\s+/, '').split(',')
|
||||
|
||||
emails.each do |email|
|
||||
break unless @account.within_email_rate_limit?
|
||||
|
||||
email = parse_email_variables(@conversation, email)
|
||||
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, email)&.deliver_later
|
||||
@account.increment_email_sent_count
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ class AutoAssignment::AssignmentService
|
||||
|
||||
def perform_bulk_assignment(limit: 100)
|
||||
return 0 unless inbox.auto_assignment_v2_enabled?
|
||||
return 0 unless inbox.enable_auto_assignment?
|
||||
|
||||
assigned_count = 0
|
||||
|
||||
@@ -32,7 +33,9 @@ class AutoAssignment::AssignmentService
|
||||
def unassigned_conversations(limit)
|
||||
scope = inbox.conversations.unassigned.open
|
||||
|
||||
scope = if assignment_config['conversation_priority'].to_s == 'longest_waiting'
|
||||
# Apply conversation priority using assignment policy if available
|
||||
policy = inbox.assignment_policy
|
||||
scope = if policy&.longest_waiting?
|
||||
scope.reorder(last_activity_at: :asc, created_at: :asc)
|
||||
else
|
||||
scope.reorder(created_at: :asc)
|
||||
@@ -81,10 +84,6 @@ class AutoAssignment::AssignmentService
|
||||
def round_robin_selector
|
||||
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
|
||||
end
|
||||
|
||||
def assignment_config
|
||||
@assignment_config ||= inbox.auto_assignment_config || {}
|
||||
end
|
||||
end
|
||||
|
||||
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')
|
||||
|
||||
@@ -8,8 +8,6 @@ class AutoAssignment::RateLimiter
|
||||
end
|
||||
|
||||
def track_assignment(conversation)
|
||||
return unless enabled?
|
||||
|
||||
assignment_key = build_assignment_key(conversation.id)
|
||||
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
|
||||
end
|
||||
@@ -24,11 +22,11 @@ class AutoAssignment::RateLimiter
|
||||
private
|
||||
|
||||
def enabled?
|
||||
limit.present? && limit.positive?
|
||||
config.present? && limit.positive?
|
||||
end
|
||||
|
||||
def limit
|
||||
config&.fair_distribution_limit&.to_i || Math
|
||||
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY
|
||||
end
|
||||
|
||||
def window
|
||||
|
||||
@@ -58,7 +58,10 @@ class AutomationRules::ActionService < ActionService
|
||||
teams = Team.where(id: params[0][:team_ids])
|
||||
|
||||
teams.each do |team|
|
||||
break unless @account.within_email_rate_limit?
|
||||
|
||||
TeamNotifications::AutomationNotificationMailer.conversation_creation(@conversation, team, params[0][:message])&.deliver_now
|
||||
@account.increment_email_sent_count
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,6 @@ class MessageTemplates::HookExecutionService
|
||||
pattr_initialize [:message!]
|
||||
|
||||
def perform
|
||||
return if conversation.campaign.present?
|
||||
return if conversation.last_incoming_message.blank?
|
||||
return if message.auto_reply_email?
|
||||
|
||||
@@ -21,6 +20,7 @@ class MessageTemplates::HookExecutionService
|
||||
end
|
||||
|
||||
def should_send_out_of_office_message?
|
||||
return false if conversation.campaign.present?
|
||||
# should not send if its a tweet message
|
||||
return false if conversation.tweet?
|
||||
# should not send for outbound messages
|
||||
@@ -37,6 +37,7 @@ class MessageTemplates::HookExecutionService
|
||||
end
|
||||
|
||||
def should_send_greeting?
|
||||
return false if conversation.campaign.present?
|
||||
# should not send if its a tweet message
|
||||
return false if conversation.tweet?
|
||||
|
||||
@@ -49,6 +50,8 @@ class MessageTemplates::HookExecutionService
|
||||
|
||||
# TODO: we should be able to reduce this logic once we have a toggle for email collect messages
|
||||
def should_send_email_collect?
|
||||
return false if conversation.campaign.present?
|
||||
|
||||
!contact_has_email? && inbox.web_widget? && !email_collect_was_sent?
|
||||
end
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
require 'faraday/multipart'
|
||||
|
||||
# Telegram Attachment APIs: ref: https://core.telegram.org/bots/api#inputfile
|
||||
|
||||
# Media attachments like photos, videos can be clubbed together and sent as a media group
|
||||
@@ -111,17 +113,33 @@ class Telegram::SendAttachmentsService
|
||||
|
||||
def send_file(chat_id, file_path, reply_to_message_id)
|
||||
File.open(file_path, 'rb') do |file|
|
||||
HTTParty.post("#{channel.telegram_api_url}/sendDocument",
|
||||
body: {
|
||||
chat_id: chat_id,
|
||||
**business_connection_body,
|
||||
document: file,
|
||||
reply_to_message_id: reply_to_message_id
|
||||
},
|
||||
multipart: true)
|
||||
file_name = File.basename(file_path)
|
||||
mime_type = Marcel::MimeType.for(name: file_name) || 'application/octet-stream'
|
||||
|
||||
payload = { chat_id: chat_id, document: Faraday::Multipart::FilePart.new(file, mime_type, file_name) }
|
||||
payload[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
|
||||
payload.merge!(business_connection_body)
|
||||
|
||||
response = multipart_post_connection.post("#{channel.telegram_api_url}/sendDocument", payload)
|
||||
parse_faraday_response(response)
|
||||
end
|
||||
end
|
||||
|
||||
def multipart_post_connection
|
||||
@multipart_post_connection ||= Faraday.new do |f|
|
||||
f.request :multipart
|
||||
f.options.timeout = 300
|
||||
f.options.open_timeout = 60
|
||||
end
|
||||
end
|
||||
|
||||
def parse_faraday_response(response)
|
||||
parsed = JSON.parse(response.body)
|
||||
OpenStruct.new(success?: response.success?, parsed_response: parsed)
|
||||
rescue JSON::ParserError
|
||||
OpenStruct.new(success?: false, parsed_response: { 'ok' => false, 'error_code' => response.status, 'description' => response.reason_phrase })
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
return true if response.success?
|
||||
|
||||
|
||||
@@ -175,6 +175,9 @@ class Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
|
||||
def create_message(message, source_id: nil)
|
||||
content_attrs = outgoing_echo ? { external_echo: true } : {}
|
||||
content_attrs[:in_reply_to_external_id] = @in_reply_to_external_id if @in_reply_to_external_id.present?
|
||||
|
||||
@message = @conversation.messages.build(
|
||||
content: message_content(message),
|
||||
account_id: @inbox.account_id,
|
||||
@@ -184,8 +187,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
status: outgoing_echo ? :delivered : :sent,
|
||||
sender: outgoing_echo ? nil : @contact,
|
||||
source_id: (source_id || message[:id]).to_s,
|
||||
content_attributes: outgoing_echo ? { external_echo: true } : {},
|
||||
in_reply_to_external_id: @in_reply_to_external_id
|
||||
content_attributes: content_attrs
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -241,3 +241,7 @@
|
||||
display_name: Required Conversation Attributes
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: advanced_assignment
|
||||
display_name: Advanced Assignment
|
||||
enabled: false
|
||||
premium: true
|
||||
|
||||
@@ -41,8 +41,7 @@ en:
|
||||
invalid_email: 'Please enter a valid email address'
|
||||
authentication_failed: 'Authentication failed. Please check your credentials and try again.'
|
||||
messages:
|
||||
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
|
||||
reset_password_failure: Uh ho! We could not find any user with the specified email.
|
||||
reset_password: Request for password reset is successful. A email with instructions will be sent to your email if it exists.
|
||||
reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
|
||||
login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
|
||||
saml_not_available: SAML authentication is not available in this installation.
|
||||
|
||||
@@ -446,6 +446,7 @@ Rails.application.routes.draw do
|
||||
get :bot_metrics
|
||||
get :inbox_label_matrix
|
||||
get :first_response_time_distribution
|
||||
get :outgoing_messages_count
|
||||
end
|
||||
end
|
||||
resource :year_in_review, only: [:show]
|
||||
|
||||
+7
-6
@@ -33,12 +33,13 @@ remove_stale_redis_keys_job.rb:
|
||||
class: 'Internal::RemoveStaleRedisKeysJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
#executed daily at 0430 UTC
|
||||
# which will be IST 10:00 AM
|
||||
process_stale_contacts_job:
|
||||
cron: '30 04 * * *'
|
||||
class: 'Internal::ProcessStaleContactsJob'
|
||||
queue: housekeeping
|
||||
# DISABLED: investigating if this job is the source of orphan conversations
|
||||
# #executed daily at 0430 UTC
|
||||
# # which will be IST 10:00 AM
|
||||
# process_stale_contacts_job:
|
||||
# cron: '30 04 * * *'
|
||||
# class: 'Internal::ProcessStaleContactsJob'
|
||||
# queue: housekeeping
|
||||
|
||||
# executed daily at 0100 UTC
|
||||
# to delete accounts marked for deletion
|
||||
|
||||
@@ -2,6 +2,9 @@ require 'administrate/field/base'
|
||||
|
||||
class AccountLimitsField < Administrate::Field::Base
|
||||
def to_s
|
||||
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil }.to_json
|
||||
defaults = { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil }
|
||||
overrides = (data.presence || {}).to_h.symbolize_keys.compact
|
||||
|
||||
defaults.merge(overrides).to_json
|
||||
end
|
||||
end
|
||||
|
||||
@@ -93,6 +93,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def send_out_of_office_message_if_applicable
|
||||
# Campaign conversations should never receive OOO templates — the campaign itself
|
||||
# serves as the initial outreach, and OOO would be confusing in that context.
|
||||
return if @conversation.campaign.present?
|
||||
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
|
||||
end
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@ module Enterprise::Account
|
||||
# this is a temporary method since current administrate doesn't support virtual attributes
|
||||
def manually_managed_features; end
|
||||
|
||||
# Auto-sync advanced_assignment with assignment_v2 when features are bulk-updated via admin UI
|
||||
def selected_feature_flags=(features)
|
||||
super
|
||||
sync_assignment_features
|
||||
end
|
||||
|
||||
def mark_for_deletion(reason = 'manual_deletion')
|
||||
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
|
||||
|
||||
@@ -31,4 +37,21 @@ module Enterprise::Account
|
||||
def saml_enabled?
|
||||
saml_settings&.saml_enabled? || false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_assignment_features
|
||||
if feature_enabled?('assignment_v2')
|
||||
# Enable advanced_assignment for Business/Enterprise plans
|
||||
send('feature_advanced_assignment=', true) if business_or_enterprise_plan?
|
||||
else
|
||||
# Disable advanced_assignment when assignment_v2 is disabled
|
||||
send('feature_advanced_assignment=', false)
|
||||
end
|
||||
end
|
||||
|
||||
def business_or_enterprise_plan?
|
||||
plan_name = custom_attributes['plan_name']
|
||||
%w[Business Enterprise].include?(plan_name)
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user