Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d661fb7955 | ||
|
|
7cec4ebaae | ||
|
|
6be95e79f8 | ||
|
|
3fba205993 | ||
|
|
2b85275e26 | ||
|
|
2a08e1d727 | ||
|
|
5b167b5b5b | ||
|
|
aae7212378 | ||
|
|
0966894794 | ||
|
|
85aed38cc7 | ||
|
|
b220663785 | ||
|
|
a6aa2c8941 | ||
|
|
40da358dc2 | ||
|
|
957a1b17c9 | ||
|
|
9dd13b9a2b | ||
|
|
540135a1cb | ||
|
|
7666b381ae | ||
|
|
48a7388127 | ||
|
|
aeb3607ac9 | ||
|
|
5ef4e2fd88 | ||
|
|
2441487a76 | ||
|
|
c85598fe8b | ||
|
|
418bd177f8 | ||
|
|
572f5b2709 | ||
|
|
15f25f019e | ||
|
|
280ca06e5b | ||
|
|
de0b78e273 | ||
|
|
2bd1d88d50 | ||
|
|
1345f67966 | ||
|
|
0f914fa2ab | ||
|
|
59663dd558 |
@@ -191,7 +191,7 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents'
|
||||
gem 'ai-agents', '>= 0.9.1'
|
||||
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm', '>= 1.8.2'
|
||||
|
||||
+2
-2
@@ -126,7 +126,7 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.9.0)
|
||||
ai-agents (0.9.1)
|
||||
ruby_llm (~> 1.9.1)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
@@ -1024,7 +1024,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents
|
||||
ai-agents (>= 0.9.1)
|
||||
annotaterb
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.11.0
|
||||
4.11.1
|
||||
|
||||
@@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def meta
|
||||
result = conversation_finder.perform
|
||||
result = conversation_finder.perform_meta_only
|
||||
@conversations_count = result[:count]
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inbox
|
||||
before_action :validate_whatsapp_channel
|
||||
before_action :validate_captain_enabled, only: [:analyze]
|
||||
|
||||
def show
|
||||
service = CsatTemplateManagementService.new(@inbox)
|
||||
@@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def analyze
|
||||
template_params = extract_template_params
|
||||
return render_missing_message_error if template_params[:message].blank?
|
||||
|
||||
result = CsatTemplateUtilityAnalysisService.new(
|
||||
account: Current.account,
|
||||
inbox: @inbox,
|
||||
message: template_params[:message],
|
||||
button_text: template_params[:button_text],
|
||||
language: template_params[:language]
|
||||
).perform
|
||||
|
||||
render json: result
|
||||
rescue ActionController::ParameterMissing
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -46,6 +64,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def validate_captain_enabled
|
||||
return if Current.account.feature_enabled?('captain_integration')
|
||||
|
||||
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
|
||||
end
|
||||
|
||||
def render_template_creation_result(result)
|
||||
if result[:success]
|
||||
render_successful_template_creation(result)
|
||||
|
||||
@@ -55,6 +55,22 @@ class ConversationFinder
|
||||
}
|
||||
end
|
||||
|
||||
def perform_meta_only
|
||||
set_up
|
||||
|
||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||
assigned_count = all_count - unassigned_count
|
||||
|
||||
{
|
||||
count: {
|
||||
mine_count: mine_count,
|
||||
assigned_count: assigned_count,
|
||||
unassigned_count: unassigned_count,
|
||||
all_count: all_count
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_up
|
||||
|
||||
@@ -57,39 +57,35 @@ module Api::V1::InboxesHelper
|
||||
end
|
||||
|
||||
def check_smtp_connection(channel_data, smtp)
|
||||
smtp.open_timeout = 10
|
||||
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
|
||||
channel_data[:smtp_authentication]&.to_sym || :login)
|
||||
smtp.finish
|
||||
rescue Net::SMTPAuthenticationError
|
||||
raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error')
|
||||
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
|
||||
raise StandardError, I18n.t('errors.inboxes.smtp.connection_error')
|
||||
rescue OpenSSL::SSL::SSLError
|
||||
raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error')
|
||||
rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError
|
||||
raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error')
|
||||
rescue StandardError => e
|
||||
raise StandardError, e.message
|
||||
end
|
||||
|
||||
def set_smtp_encryption(channel_data, smtp)
|
||||
if channel_data[:smtp_enable_ssl_tls]
|
||||
set_enable_tls(channel_data, smtp)
|
||||
set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
|
||||
elsif channel_data[:smtp_enable_starttls_auto]
|
||||
set_enable_starttls_auto(channel_data, smtp)
|
||||
set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
|
||||
end
|
||||
end
|
||||
|
||||
def set_enable_starttls_auto(channel_data, smtp)
|
||||
return unless smtp.respond_to?(:enable_starttls_auto)
|
||||
def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
|
||||
return unless smtp.respond_to?(method)
|
||||
|
||||
if channel_data[:smtp_openssl_verify_mode]
|
||||
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
|
||||
smtp.enable_starttls_auto(context)
|
||||
else
|
||||
smtp.enable_starttls_auto
|
||||
end
|
||||
end
|
||||
|
||||
def set_enable_tls(channel_data, smtp)
|
||||
return unless smtp.respond_to?(:enable_tls)
|
||||
|
||||
if channel_data[:smtp_openssl_verify_mode]
|
||||
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
|
||||
smtp.enable_tls(context)
|
||||
else
|
||||
smtp.enable_tls
|
||||
end
|
||||
context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
|
||||
context ? smtp.send(method, context) : smtp.send(method)
|
||||
end
|
||||
|
||||
def enable_openssl_mode(smtp_openssl_verify_mode)
|
||||
|
||||
@@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
getCSATTemplateStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||
}
|
||||
|
||||
analyzeCSATTemplateUtility(inboxId, template) {
|
||||
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
|
||||
template,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
|
||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||
INSTAGRAM: 'i-ri-instagram-line',
|
||||
TELEGRAM: 'i-ri-telegram-fill',
|
||||
TIKTOK: 'i-ri-tiktok-fill',
|
||||
TWITTER: 'i-ri-twitter-x-fill',
|
||||
GITHUB: 'i-ri-github-fill',
|
||||
@@ -66,6 +67,7 @@ const defaultState = {
|
||||
facebook: '',
|
||||
github: '',
|
||||
instagram: '',
|
||||
telegram: '',
|
||||
tiktok: '',
|
||||
linkedin: '',
|
||||
twitter: '',
|
||||
@@ -103,9 +105,13 @@ const prepareStateBasedOnProps = () => {
|
||||
countryCode = '',
|
||||
country = '',
|
||||
city = '',
|
||||
socialTelegramUserName = '',
|
||||
socialProfiles = {},
|
||||
} = additionalAttributes || {};
|
||||
|
||||
const telegramUsername =
|
||||
socialProfiles?.telegram || socialTelegramUserName || '';
|
||||
|
||||
Object.assign(state, {
|
||||
id,
|
||||
name,
|
||||
@@ -119,7 +125,10 @@ const prepareStateBasedOnProps = () => {
|
||||
countryCode,
|
||||
country,
|
||||
city,
|
||||
socialProfiles,
|
||||
socialProfiles: {
|
||||
...socialProfiles,
|
||||
telegram: telegramUsername,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contact: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
enableSelection: { type: Boolean, default: true },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectConversation']);
|
||||
|
||||
const hovered = ref(false);
|
||||
|
||||
const onThumbnailHover = () => {
|
||||
hovered.value = !props.hideThumbnail;
|
||||
};
|
||||
|
||||
const onThumbnailLeave = () => {
|
||||
hovered.value = false;
|
||||
};
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
emit('selectConversation', value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-center flex-shrink-0"
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<Avatar
|
||||
v-if="!hideThumbnail"
|
||||
:name="contact.name"
|
||||
:src="contact.thumbnail"
|
||||
:size="24"
|
||||
:status="contact.availability_status"
|
||||
hide-offline-status
|
||||
>
|
||||
<template v-if="enableSelection" #overlay>
|
||||
<div
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
</template>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import UnreadBadge from './UnreadBadge.vue';
|
||||
|
||||
defineProps({
|
||||
lastMessage: { type: Object, default: null },
|
||||
voiceCallStatus: { type: String, default: '' },
|
||||
voiceCallDirection: { type: String, default: '' },
|
||||
unreadCount: { type: Number, default: 0 },
|
||||
showExpandedPreview: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_auto] gap-1.5"
|
||||
:class="showExpandedPreview ? 'items-end' : 'items-center'"
|
||||
>
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallStatus"
|
||||
key="voice-status-row"
|
||||
:status="voiceCallStatus"
|
||||
:direction="voiceCallDirection"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessage"
|
||||
key="message-preview"
|
||||
:message="lastMessage"
|
||||
:multi-line="showExpandedPreview"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
>
|
||||
<Icon icon="i-lucide-info" class="size-3.5" />
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
|
||||
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
|
||||
</div>
|
||||
</template>
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, useSlots, watch, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
disableToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const attrs = useAttrs();
|
||||
const slots = useSlots();
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const activeLabels = computed(() => {
|
||||
return accountLabels.value.filter(({ title }) =>
|
||||
props.labels.includes(title)
|
||||
);
|
||||
});
|
||||
|
||||
const showAllLabels = ref(false);
|
||||
const showExpandLabelButton = ref(false);
|
||||
const labelPosition = ref(-1);
|
||||
const labelContainer = ref(null);
|
||||
|
||||
// Show if there are labels OR if before slot exists
|
||||
const showSection = computed(
|
||||
() => activeLabels.value.length > 0 || !!slots.before
|
||||
);
|
||||
|
||||
const computeVisibleLabelPosition = () => {
|
||||
const container = labelContainer.value;
|
||||
if (!container || activeLabels.value.length === 0) {
|
||||
showExpandLabelButton.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = container.querySelectorAll('[data-label]');
|
||||
if (labels.length === 0) {
|
||||
showExpandLabelButton.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Early exit if all labels are visible
|
||||
if (showAllLabels.value) return;
|
||||
|
||||
const beforeSlot = container.querySelector('[data-before-slot]');
|
||||
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
|
||||
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
|
||||
|
||||
let totalWidth = 0;
|
||||
const labelsArray = Array.from(labels);
|
||||
|
||||
// Find last visible label index using some() - stops early on overflow
|
||||
const overflowIndex = labelsArray.findIndex(label => {
|
||||
totalWidth += label.offsetWidth + 6;
|
||||
return totalWidth > availableWidth;
|
||||
});
|
||||
|
||||
const visibleIndex =
|
||||
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
|
||||
|
||||
labelPosition.value = visibleIndex;
|
||||
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
|
||||
};
|
||||
|
||||
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
|
||||
|
||||
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
|
||||
|
||||
const hiddenLabelsCount = computed(() => {
|
||||
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
|
||||
return activeLabels.value.length - labelPosition.value - 1;
|
||||
});
|
||||
|
||||
// Check if all labels are hidden (none visible)
|
||||
const allLabelsHidden = computed(() => {
|
||||
return labelPosition.value === -1 && activeLabels.value.length > 0;
|
||||
});
|
||||
|
||||
// Label text for button when disableToggle is true and all labels are hidden
|
||||
const labelsCountText = computed(() => {
|
||||
if (props.disableToggle && allLabelsHidden.value) {
|
||||
return t('CONVERSATION.CARD.LABELS_COUNT', {
|
||||
count: activeLabels.value.length,
|
||||
});
|
||||
}
|
||||
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
|
||||
return hiddenLabelsCount.value;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const hiddenLabelsTooltip = computed(() => {
|
||||
if (!props.disableToggle) return '';
|
||||
// When all labels are hidden, show all label titles
|
||||
if (allLabelsHidden.value) {
|
||||
return activeLabels.value.map(label => label.title).join(', ');
|
||||
}
|
||||
if (!showExpandLabelButton.value) return '';
|
||||
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
|
||||
return hiddenLabels.map(label => label.title).join(', ');
|
||||
});
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (props.disableToggle && hiddenLabelsTooltip.value) {
|
||||
return hiddenLabelsTooltip.value;
|
||||
}
|
||||
return showAllLabels.value
|
||||
? t('CONVERSATION.CARD.HIDE_LABELS')
|
||||
: t('CONVERSATION.CARD.SHOW_LABELS');
|
||||
});
|
||||
|
||||
const onShowLabels = e => {
|
||||
e.stopPropagation();
|
||||
if (props.disableToggle) return;
|
||||
showAllLabels.value = !showAllLabels.value;
|
||||
nextTick(() => computeVisibleLabelPosition());
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showSection"
|
||||
ref="labelContainer"
|
||||
v-bind="attrs"
|
||||
v-resize="throttledCalculate"
|
||||
data-labels-container
|
||||
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
|
||||
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
|
||||
>
|
||||
<slot name="before" />
|
||||
|
||||
<Label
|
||||
v-for="(label, index) in activeLabels"
|
||||
:key="label ? label.id : index"
|
||||
data-label
|
||||
:label="label"
|
||||
compact
|
||||
:class="{
|
||||
'invisible absolute': !showAllLabels && index > labelPosition,
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
|
||||
v-tooltip.top="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1000, hide: 0 },
|
||||
}"
|
||||
:label="labelsCountText"
|
||||
xs
|
||||
slate
|
||||
:no-animation="disableToggle"
|
||||
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
|
||||
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
|
||||
:class="{ 'cursor-default': disableToggle }"
|
||||
@click="onShowLabels"
|
||||
/>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
+30
-195
@@ -1,207 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
|
||||
defineProps({
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const icons = {
|
||||
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
|
||||
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
|
||||
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
|
||||
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
|
||||
};
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.priority && icons[props.priority]) {
|
||||
return icons[props.priority];
|
||||
}
|
||||
return props.showEmpty ? 'i-woot-priority-empty' : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: priority,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 text-n-slate-5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { CONVERSATION_STATUS } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const icons = {
|
||||
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
|
||||
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
|
||||
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
|
||||
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
|
||||
};
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.status && icons[props.status]) {
|
||||
return icons[props.status];
|
||||
}
|
||||
return props.showEmpty ? 'i-woot-status-empty' : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: status,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
|
||||
import CardAvatar from './CardAvatar.vue';
|
||||
import CardContent from './CardContent.vue';
|
||||
import CardLabels from './CardLabelsV5.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
enableSelection: { type: Boolean, default: true },
|
||||
isInboxView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectConversation', 'click', 'contextmenu']);
|
||||
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const unreadCount = computed(() => props.chat?.unread_count);
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const showLabelsSection = computed(
|
||||
() => props.chat.labels?.length > 0 || hasSlaPolicyId.value
|
||||
);
|
||||
|
||||
const showMetaSection = computed(
|
||||
() =>
|
||||
(!props.isInboxView && props.showInboxName) ||
|
||||
(props.showAssignee && props.assignee?.name) ||
|
||||
props.chat.priority
|
||||
);
|
||||
|
||||
const onSelectConversation = checked => {
|
||||
emit('selectConversation', checked);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-start max-w-full cursor-pointer group transition-colors duration-150 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
|
||||
'bg-n-slate-2': selected,
|
||||
'px-0': compact,
|
||||
'px-2 rounded-lg': !compact,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<div class="relative">
|
||||
<CardAvatar
|
||||
v-if="!compact && !hideThumbnail"
|
||||
:contact="currentContact"
|
||||
:selected="selected"
|
||||
:enable-selection="enableSelection"
|
||||
:hide-thumbnail="hideThumbnail"
|
||||
:class="showMetaSection ? 'mt-8' : 'mt-4'"
|
||||
@select-conversation="onSelectConversation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
|
||||
>
|
||||
<div
|
||||
v-if="showMetaSection"
|
||||
class="flex items-center min-w-0 gap-1"
|
||||
:class="{
|
||||
'ltr:ml-2 rtl:mr-2': !compact,
|
||||
'mx-2': compact,
|
||||
}"
|
||||
>
|
||||
<InboxName
|
||||
v-if="showInboxName && !isInboxView"
|
||||
:inbox="inbox"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<div
|
||||
v-if="showAssignee || chat.priority"
|
||||
class="flex items-center gap-2 flex-shrink-0 h-4"
|
||||
:class="{
|
||||
'flex-1 justify-between': isInboxView || !showInboxName,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="showAssignee && assignee.name"
|
||||
class="text-n-slate-11 text-label-small px-0 inline-flex items-center truncate gap-0.5 max-w-36"
|
||||
>
|
||||
<Icon icon="i-lucide-user-round" class="text-n-slate-11 size-3" />
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
<CardPriorityIcon
|
||||
v-if="chat.priority"
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex items-center justify-between w-full">
|
||||
<h4
|
||||
class="text-heading-3 mx-2 capitalize pt-0.5 truncate flex-1 min-w-0 text-n-slate-12"
|
||||
:class="hasUnread ? '!font-520' : ''"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
|
||||
<span class="text-label-small text-n-slate-11">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="ltr:ml-2 rtl:mr-2 leading-6 h-6 min-w-0">
|
||||
<CardContent
|
||||
:last-message="lastMessageInChat"
|
||||
:voice-call-status="voiceCallData.status"
|
||||
:voice-call-direction="voiceCallData.direction"
|
||||
:unread-count="unreadCount"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardLabels
|
||||
v-if="showLabelsSection"
|
||||
:labels="chat.labels"
|
||||
class="mt-0.5 mx-2"
|
||||
>
|
||||
<template v-if="hasSlaPolicyId" #before>
|
||||
<SLACardLabel ref="slaCardLabel" :chat="chat" data-before-slot />
|
||||
</template>
|
||||
</CardLabels>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import CardAvatar from './CardAvatar.vue';
|
||||
import CardContent from './CardContent.vue';
|
||||
import CardLabels from './CardLabelsV5.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
|
||||
import CardStatusIcon from './CardStatusIcon.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
isInboxView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
]);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
if (value) {
|
||||
emit('selectConversation', value);
|
||||
} else {
|
||||
emit('deSelectConversation', value);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative cursor-pointer group transition-colors duration-150 grid gap-4 items-center px-2 h-12"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
|
||||
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
|
||||
'hover:bg-n-alpha-1 rounded-lg': !isActiveChat && !selected,
|
||||
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
|
||||
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<!-- LEFT SECTION -->
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardPriorityIcon :priority="chat.priority" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="{
|
||||
content: assignee.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-woot-empty-assignee"
|
||||
class="size-4 text-n-slate-7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardStatusIcon :status="chat.status" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div v-if="!isInboxView" class="w-20 flex-shrink-0">
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isInboxView" class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: chat.id,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-hash"
|
||||
class="size-3.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-label-small text-n-slate-11 truncate">
|
||||
{{ chat.id }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CardAvatar
|
||||
:contact="currentContact"
|
||||
:selected="false"
|
||||
:enable-selection="false"
|
||||
:hide-thumbnail="false"
|
||||
/>
|
||||
|
||||
<h4
|
||||
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
|
||||
<CardContent
|
||||
:last-message="lastMessageInChat"
|
||||
:voice-call-status="voiceCallData.status"
|
||||
:voice-call-direction="voiceCallData.direction"
|
||||
:unread-count="unreadCount"
|
||||
:show-expanded-preview="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT SECTION -->
|
||||
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
|
||||
<div v-if="showLabelsSection" class="min-w-0 w-full">
|
||||
<CardLabels
|
||||
:labels="chat.labels"
|
||||
disable-toggle
|
||||
class="my-0 [&>div]:justify-end justify-end"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
|
||||
<SLACardLabel ref="slaCardLabel" :chat="chat" />
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 w-[4.375rem] text-end">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
class="font-440 !text-xs text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
<script setup>
|
||||
import { computed, ref, inject } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import ConversationContextMenu from 'dashboard/components/widgets/conversation/contextMenu/Index.vue';
|
||||
import ConversationCardExpanded from './ConversationCardExpanded.vue';
|
||||
import ConversationCardCompact from './ConversationCardCompact.vue';
|
||||
// import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeLabel: { type: String, default: '' },
|
||||
chat: { type: Object, default: () => ({}) },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
conversationType: { type: String, default: '' },
|
||||
selected: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
enableContextMenu: { type: Boolean, default: false },
|
||||
allowedContextMenuOptions: { type: Array, default: () => [] },
|
||||
enableSelection: { type: Boolean, default: true },
|
||||
isExpandedLayout: { type: Boolean, default: false },
|
||||
isPreviousConversations: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'contextMenuToggle',
|
||||
'assignAgent',
|
||||
'assignLabel',
|
||||
'removeLabel',
|
||||
'assignTeam',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'updateConversationStatus',
|
||||
'deleteConversation',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isLargeScreen = inject('isLargeScreen', ref(false));
|
||||
|
||||
// Show expanded only when: isExpandedLayout=true AND screen >= 1024px
|
||||
const showExpanded = computed(() => {
|
||||
return props.isExpandedLayout && isLargeScreen.value;
|
||||
});
|
||||
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const contactGetter = useMapGetter('contacts/getContact');
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const chatMetadata = computed(() => props.chat.meta || {});
|
||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||
const currentContact = computed(() =>
|
||||
senderId.value ? contactGetter.value(senderId.value) : {}
|
||||
);
|
||||
const hasActiveInbox = computed(() => activeInbox.value);
|
||||
|
||||
const isActiveChat = computed(() => currentChat.value.id === props.chat.id);
|
||||
const inbox = computed(() => {
|
||||
const inboxId = props.chat.inbox_id;
|
||||
return inboxId ? inboxGetter.value(inboxId) : {};
|
||||
});
|
||||
|
||||
const showInboxName = computed(() => inboxesList.value.length > 1);
|
||||
|
||||
const conversationPath = computed(() => {
|
||||
return frontendURL(
|
||||
conversationUrl({
|
||||
accountId: accountId.value,
|
||||
activeInbox: activeInbox.value,
|
||||
id: props.chat.id,
|
||||
label: props.activeLabel,
|
||||
teamId: props.teamId,
|
||||
conversationType: props.conversationType,
|
||||
foldersId: props.foldersId,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const onCardClick = e => {
|
||||
const path = conversationPath.value;
|
||||
if (!path) return;
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveChat.value) return;
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const onSelectConversation = checked => {
|
||||
if (checked) {
|
||||
emit('selectConversation', props.chat.id, inbox.value.id);
|
||||
} else {
|
||||
emit('deSelectConversation', props.chat.id, inbox.value.id);
|
||||
}
|
||||
};
|
||||
|
||||
const openContextMenu = e => {
|
||||
if (!props.enableContextMenu) return;
|
||||
e.preventDefault();
|
||||
emit('contextMenuToggle', true);
|
||||
contextMenu.value.x = e.pageX || e.clientX;
|
||||
contextMenu.value.y = e.pageY || e.clientY;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
emit('contextMenuToggle', false);
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value.x = null;
|
||||
contextMenu.value.y = null;
|
||||
};
|
||||
|
||||
const onUpdateConversation = (status, snoozedUntil) => {
|
||||
closeContextMenu();
|
||||
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
|
||||
};
|
||||
|
||||
const onAssignAgent = agent => {
|
||||
emit('assignAgent', agent, [props.chat.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignLabel = label => {
|
||||
emit('assignLabel', [label.title], [props.chat.id]);
|
||||
};
|
||||
|
||||
const onRemoveLabel = label => {
|
||||
emit('removeLabel', [label.title], [props.chat.id]);
|
||||
};
|
||||
|
||||
const onAssignTeam = team => {
|
||||
emit('assignTeam', team, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsUnread = () => {
|
||||
emit('markAsUnread', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsRead = () => {
|
||||
emit('markAsRead', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const assignPriority = priority => {
|
||||
emit('assignPriority', priority, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const deleteConversation = () => {
|
||||
emit('deleteConversation', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Expanded: Only when isExpandedLayout=true AND screen >= 1024px -->
|
||||
<ConversationCardExpanded
|
||||
v-if="showExpanded"
|
||||
:chat="chat"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
:inbox="inbox"
|
||||
:selected="selected"
|
||||
:is-active-chat="isActiveChat"
|
||||
:show-assignee="showAssignee"
|
||||
:show-inbox-name="showInboxName"
|
||||
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
|
||||
@select-conversation="onSelectConversation"
|
||||
@de-select-conversation="onSelectConversation"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu"
|
||||
/>
|
||||
|
||||
<!-- Compact: All other cases (mobile OR isExpandedLayout=false) -->
|
||||
<ConversationCardCompact
|
||||
v-else
|
||||
:chat="chat"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
:inbox="inbox"
|
||||
:selected="selected"
|
||||
:is-active-chat="isActiveChat"
|
||||
:compact="compact"
|
||||
:show-assignee="showAssignee"
|
||||
:show-inbox-name="showInboxName"
|
||||
:hide-thumbnail="hideThumbnail"
|
||||
:enable-selection="enableSelection"
|
||||
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
|
||||
@select-conversation="onSelectConversation"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu"
|
||||
/>
|
||||
|
||||
<ContextMenu
|
||||
v-if="showContextMenu"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@close="closeContextMenu"
|
||||
>
|
||||
<ConversationContextMenu
|
||||
:status="chat.status"
|
||||
:inbox-id="inbox.id"
|
||||
:priority="chat.priority"
|
||||
:chat-id="chat.id"
|
||||
:has-unread-messages="chat.unread_count > 0"
|
||||
:conversation-labels="chat.labels"
|
||||
:conversation-url="conversationPath"
|
||||
:allowed-options="allowedContextMenuOptions"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@remove-label="onRemoveLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showMessageType: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultEmptyMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
multiLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const attachmentIcons = {
|
||||
image: 'i-lucide-image',
|
||||
audio: 'i-lucide-headphones',
|
||||
video: 'i-lucide-video',
|
||||
file: 'i-lucide-file',
|
||||
location: 'i-lucide-map-pin',
|
||||
fallback: 'i-lucide-link-2',
|
||||
};
|
||||
|
||||
const messageByAgent = computed(() => {
|
||||
const { message_type: messageType } = props.message;
|
||||
return messageType === MESSAGE_TYPE.OUTGOING;
|
||||
});
|
||||
|
||||
const isMessageAnActivity = computed(() => {
|
||||
const { message_type: messageType } = props.message;
|
||||
return messageType === MESSAGE_TYPE.ACTIVITY;
|
||||
});
|
||||
|
||||
const isMessagePrivate = computed(() => {
|
||||
const { private: isPrivate } = props.message;
|
||||
return isPrivate;
|
||||
});
|
||||
|
||||
const parsedLastMessage = computed(() => {
|
||||
const { content_attributes: contentAttributes } = props.message;
|
||||
const { email: { subject } = {} } = contentAttributes || {};
|
||||
return getPlainText(subject || props.message.content);
|
||||
});
|
||||
|
||||
const lastMessageFileType = computed(() => {
|
||||
const [{ file_type: fileType } = {}] = props.message.attachments;
|
||||
return fileType;
|
||||
});
|
||||
|
||||
const attachmentIcon = computed(() => {
|
||||
return attachmentIcons[lastMessageFileType.value];
|
||||
});
|
||||
|
||||
const attachmentMessageContent = computed(() => {
|
||||
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
|
||||
});
|
||||
|
||||
const isMessageSticker = computed(() => {
|
||||
return props.message && props.message.content_type === 'sticker';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-w-0 text-sm"
|
||||
:class="
|
||||
multiLine
|
||||
? 'flex items-start gap-1'
|
||||
: 'grid grid-cols-[auto_1fr] items-center gap-1'
|
||||
"
|
||||
>
|
||||
<template v-if="showMessageType && !multiLine">
|
||||
<Icon
|
||||
v-if="isMessagePrivate"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="messageByAgent"
|
||||
icon="i-lucide-undo-2"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
icon="i-lucide-info"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<span
|
||||
class="min-w-0 text-body-main"
|
||||
:class="multiLine ? 'line-clamp-2' : 'truncate'"
|
||||
>
|
||||
<!-- Case for previous and conversation conversation card -->
|
||||
<template v-if="showMessageType && multiLine">
|
||||
<Icon
|
||||
v-if="isMessagePrivate"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="messageByAgent"
|
||||
icon="i-lucide-undo-2"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
icon="i-lucide-info"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="message.content && isMessageSticker"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
|
||||
>
|
||||
<Icon icon="i-lucide-image" class="size-3.5" />
|
||||
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
|
||||
</span>
|
||||
|
||||
<template v-else-if="message.content">
|
||||
{{ parsedLastMessage }}
|
||||
</template>
|
||||
|
||||
<span
|
||||
v-else-if="message.attachments"
|
||||
class="inline-block align-middle truncate"
|
||||
>
|
||||
<Icon
|
||||
v-if="attachmentIcon && showMessageType"
|
||||
:icon="attachmentIcon"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<span class="inline-block align-middle">
|
||||
{{ $t(attachmentMessageContent) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<template v-else>
|
||||
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
count: { type: Number, required: true },
|
||||
alignBottom: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const displayCount = computed(() =>
|
||||
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="count > 0"
|
||||
class="bg-n-blue-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
|
||||
:class="{
|
||||
'mb-0.5': alignBottom,
|
||||
}"
|
||||
>
|
||||
{{ displayCount }}
|
||||
</span>
|
||||
<span v-else />
|
||||
</template>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
VOICE_CALL_STATUS,
|
||||
VOICE_CALL_DIRECTION,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: String, default: '' },
|
||||
direction: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const LABEL_KEYS = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||
};
|
||||
|
||||
const isOutbound = computed(
|
||||
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
|
||||
if (props.status === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const statusColor = computed(
|
||||
() => COLOR_MAP[props.status] || 'text-n-slate-11'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-1 min-w-0 text-sm">
|
||||
<Icon class="size-3.5" :icon="iconName" :class="statusColor" />
|
||||
<span class="truncate text-body-main" :class="statusColor">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
|
||||
defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
|
||||
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
|
||||
<span class="truncate text-label-small text-n-slate-11">
|
||||
{{ inbox.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,10 +20,10 @@ const showCopilotTab = computed(() =>
|
||||
|
||||
const { uiSettings } = useUISettings();
|
||||
const isContactSidebarOpen = computed(
|
||||
() => uiSettings.value.is_contact_sidebar_open
|
||||
() => uiSettings.value.is_contact_sidebar_open || false
|
||||
);
|
||||
const isCopilotPanelOpen = computed(
|
||||
() => uiSettings.value.is_copilot_panel_open
|
||||
() => uiSettings.value.is_copilot_panel_open || false
|
||||
);
|
||||
|
||||
const toggleConversationSidebarToggle = () => {
|
||||
@@ -35,7 +35,7 @@ const toggleConversationSidebarToggle = () => {
|
||||
|
||||
const handleConversationSidebarToggle = () => {
|
||||
updateUISettings({
|
||||
is_contact_sidebar_open: true,
|
||||
is_contact_sidebar_open: !isContactSidebarOpen.value,
|
||||
is_copilot_panel_open: false,
|
||||
});
|
||||
};
|
||||
@@ -43,7 +43,7 @@ const handleConversationSidebarToggle = () => {
|
||||
const handleCopilotSidebarToggle = () => {
|
||||
updateUISettings({
|
||||
is_contact_sidebar_open: false,
|
||||
is_copilot_panel_open: true,
|
||||
is_copilot_panel_open: !isCopilotPanelOpen.value,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
showExtendedInfo: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const slaEvents = computed(() => props.chat?.sla_events);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const conversation = computed(() => useCamelCase(props.chat, { deep: true }));
|
||||
|
||||
const showSlaPopoverCard = computed(
|
||||
() => props.showExtendedInfo && slaEvents.value?.length > 0
|
||||
);
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value || {},
|
||||
chat: conversation.value,
|
||||
});
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}, REFRESH_INTERVAL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.chat, updateSlaStatus);
|
||||
|
||||
defineExpose({
|
||||
hasSlaThreshold,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="hasSlaThreshold"
|
||||
v-bind="$attrs"
|
||||
class="relative flex items-center cursor-pointer min-w-fit group"
|
||||
>
|
||||
<Label
|
||||
:label="slaStatus.threshold"
|
||||
:color="isSlaMissed ? 'ruby' : 'amber'"
|
||||
compact
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
|
||||
</template>
|
||||
</Label>
|
||||
<SLAPopoverCard
|
||||
v-if="showSlaPopoverCard"
|
||||
:sla-missed-events="slaEvents"
|
||||
class="start-0 md:start-auto md:end-0 top-7 hidden group-hover:flex"
|
||||
/>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import { format, fromUnixTime } from 'date-fns';
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const formatDate = timestamp =>
|
||||
format(fromUnixTime(timestamp), 'MMM dd, yyyy, hh:mm a');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between w-full">
|
||||
<span
|
||||
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-n-slate-11"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<span
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="text-sm font-normal text-n-slate-12 text-right tabular-nums"
|
||||
>
|
||||
{{ formatDate(item.created_at) }}
|
||||
</span>
|
||||
<slot name="showMore" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import SLAEventItem from './SLAEventItem.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
slaMissedEvents: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { SLA_MISS_TYPES } = wootConstants;
|
||||
|
||||
const shouldShowAllNrts = ref(false);
|
||||
|
||||
const frtMisses = computed(() =>
|
||||
props.slaMissedEvents.filter(
|
||||
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.FRT
|
||||
)
|
||||
);
|
||||
const nrtMisses = computed(() => {
|
||||
const missedEvents = props.slaMissedEvents.filter(
|
||||
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.NRT
|
||||
);
|
||||
return shouldShowAllNrts.value ? missedEvents : missedEvents.slice(0, 6);
|
||||
});
|
||||
const rtMisses = computed(() =>
|
||||
props.slaMissedEvents.filter(
|
||||
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.RT
|
||||
)
|
||||
);
|
||||
|
||||
const shouldShowMoreNRTButton = computed(() => nrtMisses.value.length > 6);
|
||||
const toggleShowAllNRT = () => {
|
||||
shouldShowAllNrts.value = !shouldShowAllNrts.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col items-start border-n-strong bg-n-solid-3 w-96 backdrop-blur-[100px] px-6 py-5 z-50 shadow rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('SLA.EVENTS.TITLE') }}
|
||||
</span>
|
||||
<SLAEventItem
|
||||
v-if="frtMisses.length"
|
||||
:label="$t('SLA.EVENTS.FRT')"
|
||||
:items="frtMisses"
|
||||
/>
|
||||
<SLAEventItem
|
||||
v-if="nrtMisses.length"
|
||||
:label="$t('SLA.EVENTS.NRT')"
|
||||
:items="nrtMisses"
|
||||
>
|
||||
<template #showMore>
|
||||
<div
|
||||
v-if="shouldShowMoreNRTButton"
|
||||
class="flex flex-col items-end w-full"
|
||||
>
|
||||
<Button
|
||||
link
|
||||
xs
|
||||
slate
|
||||
class="hover:!no-underline"
|
||||
:icon="!shouldShowAllNrts ? 'i-lucide-plus' : ''"
|
||||
:label="
|
||||
shouldShowAllNrts
|
||||
? $t('SLA.EVENTS.HIDE', { count: nrtMisses.length })
|
||||
: $t('SLA.EVENTS.SHOW_MORE', { count: nrtMisses.length })
|
||||
"
|
||||
@click="toggleShowAllNRT"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SLAEventItem>
|
||||
<SLAEventItem
|
||||
v-if="rtMisses.length"
|
||||
:label="$t('SLA.EVENTS.RT')"
|
||||
:items="rtMisses"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -82,7 +82,13 @@ const attributeIcon = computed(() => {
|
||||
sm
|
||||
@click="emit('edit', attribute)"
|
||||
/>
|
||||
<Button icon="i-woot-bin" slate sm @click="emit('delete', attribute)" />
|
||||
<Button
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="emit('delete', attribute)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,13 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
validator: value => {
|
||||
return value.every(item => item.action && item.value && item.label);
|
||||
return value.every(
|
||||
item =>
|
||||
item.action &&
|
||||
item.value !== undefined &&
|
||||
item.value !== null &&
|
||||
item.label
|
||||
);
|
||||
},
|
||||
},
|
||||
menuSections: {
|
||||
@@ -22,6 +28,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
roundedThumbnail: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -42,9 +52,17 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emptyStateMessage: {
|
||||
type: String,
|
||||
default: 'DROPDOWN_MENU.EMPTY_STATE',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'search']);
|
||||
const emit = defineEmits(['action', 'search', 'empty']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -96,9 +114,13 @@ const filteredMenuSections = computed(() => {
|
||||
});
|
||||
|
||||
const handleSearchInput = event => {
|
||||
if (props.disableLocalFiltering) {
|
||||
emit('search', event.target.value);
|
||||
}
|
||||
emit('search', event.target.value);
|
||||
|
||||
const isEmpty = hasSections.value
|
||||
? filteredMenuSections.value.length === 0
|
||||
: filteredMenuItems.value.length === 0;
|
||||
|
||||
if (isEmpty) emit('empty');
|
||||
};
|
||||
|
||||
const handleAction = item => {
|
||||
@@ -190,22 +212,27 @@ onMounted(() => {
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
:rounded-full="roundedThumbnail"
|
||||
/>
|
||||
</slot>
|
||||
<slot name="icon" :item="item">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<slot name="label" :item="item">
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm font-420 truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</slot>
|
||||
<slot name="trailing-icon" :item="item" />
|
||||
</button>
|
||||
<div
|
||||
v-if="sectionIndex < filteredMenuSections.length - 1"
|
||||
@@ -214,6 +241,9 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-2">
|
||||
<Spinner :size="24" />
|
||||
</div>
|
||||
<button
|
||||
v-for="(item, index) in filteredMenuItems"
|
||||
:key="index"
|
||||
@@ -233,22 +263,27 @@ onMounted(() => {
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
:rounded-full="roundedThumbnail"
|
||||
/>
|
||||
</slot>
|
||||
<slot name="icon" :item="item">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<slot name="label" :item="item">
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm font-420 truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</slot>
|
||||
<slot name="trailing-icon" :item="item" />
|
||||
</button>
|
||||
</template>
|
||||
<div
|
||||
@@ -258,7 +293,9 @@ onMounted(() => {
|
||||
{{
|
||||
isSearching
|
||||
? t('DROPDOWN_MENU.SEARCHING')
|
||||
: t('DROPDOWN_MENU.EMPTY_STATE')
|
||||
: searchQuery
|
||||
? t('DROPDOWN_MENU.EMPTY_STATE')
|
||||
: t(emptyStateMessage)
|
||||
}}
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
|
||||
@@ -39,38 +39,45 @@ const formatFilterValue = value => {
|
||||
|
||||
// Case 2: array → map each item, use name if present, else the item itself
|
||||
if (Array.isArray(value)) {
|
||||
// Empty array
|
||||
if (value.length === 0) return '';
|
||||
return value.map(item => item?.name ?? item).join(', ');
|
||||
}
|
||||
|
||||
// Case 3: object with a "name" property → return name
|
||||
// Case 4: primitive (string, number, etc.) → return as is
|
||||
// Case 3: empty object
|
||||
if (typeof value === 'object' && Object.keys(value).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Case 4: object with a "name" property → return name
|
||||
// Case 5: primitive (string, number, etc.) → return as is
|
||||
return value?.name ?? value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center w-full gap-2 mx-auto">
|
||||
<div class="flex flex-wrap items-center w-full gap-x-1.5 gap-y-2 mx-auto">
|
||||
<template v-for="(filter, index) in appliedFilters" :key="index">
|
||||
<div
|
||||
v-if="index < maxVisibleFilters"
|
||||
class="inline-flex items-center gap-2 h-7"
|
||||
class="inline-flex items-center gap-1.5 h-7"
|
||||
>
|
||||
<div
|
||||
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer"
|
||||
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer bg-n-button-color"
|
||||
@click="emit('openFilter')"
|
||||
>
|
||||
<span
|
||||
class="lowercase whitespace-nowrap first-letter:capitalize text-n-slate-12"
|
||||
class="lowercase whitespace-nowrap font-440 first-letter:capitalize text-n-slate-12"
|
||||
>
|
||||
{{ replaceUnderscoreWithSpace(filter.attributeKey) }}
|
||||
</span>
|
||||
<span class="px-1 text-xs text-n-slate-10 whitespace-nowrap">
|
||||
<span class="px-1 text-xs text-n-slate-11 font-440 whitespace-nowrap">
|
||||
{{ formatOperatorLabel(filter.filterOperator) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="filter.values"
|
||||
v-if="formatFilterValue(filter.values)"
|
||||
:title="formatFilterValue(filter.values)"
|
||||
class="lowercase truncate text-n-slate-12"
|
||||
class="lowercase truncate text-n-slate-12 font-440"
|
||||
:class="{
|
||||
'first-letter:capitalize': shouldCapitalizeFirstLetter(
|
||||
filter.attributeKey
|
||||
@@ -86,7 +93,7 @@ const formatFilterValue = value => {
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="content-center h-full px-1 text-xs font-medium uppercase rounded-lg text-n-slate-10"
|
||||
class="content-center h-full px-1 text-xs font-440 uppercase rounded-lg text-n-slate-11"
|
||||
>
|
||||
{{ filter.queryOperator }}
|
||||
</span>
|
||||
@@ -95,7 +102,7 @@ const formatFilterValue = value => {
|
||||
</template>
|
||||
<div
|
||||
v-if="appliedFilters.length > maxVisibleFilters"
|
||||
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer"
|
||||
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer font-440"
|
||||
@click="emit('openFilter')"
|
||||
>
|
||||
{{ moreFiltersLabel }}
|
||||
@@ -105,9 +112,10 @@ const formatFilterValue = value => {
|
||||
v-if="showClearButton"
|
||||
:label="clearButtonLabel"
|
||||
size="xs"
|
||||
class="!px-1"
|
||||
variant="ghost"
|
||||
class="!px-1 hover:!no-underline"
|
||||
link
|
||||
@click="emit('clearFilters')"
|
||||
/>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, onBeforeUnmount, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useBreakpoints } from '@vueuse/core';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useConversationFilterContext } from './provider.js';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
@@ -42,6 +44,13 @@ const DEFAULT_FILTER = {
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const breakpoints = useBreakpoints({
|
||||
mobile: wootConstants.SMALL_SCREEN_BREAKPOINT,
|
||||
});
|
||||
|
||||
const isSmallScreen = breakpoints.smaller('mobile');
|
||||
const viewInModal = computed(() => isSmallScreen.value);
|
||||
|
||||
const resetFilter = () => {
|
||||
filters.value = [{ ...DEFAULT_FILTER }];
|
||||
};
|
||||
@@ -104,69 +113,80 @@ const outsideClickHandler = [
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="outsideClickHandler"
|
||||
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
|
||||
:class="{
|
||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start justify-center inset-0 px-2 pt-14 pb-8 overflow-y-auto overflow-x-hidden':
|
||||
viewInModal,
|
||||
}"
|
||||
@click.self="viewInModal && emit('close')"
|
||||
>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ filterModalHeaderTitle }}
|
||||
</h3>
|
||||
<div v-if="props.isFolderView">
|
||||
<div class="border-b border-n-weak pb-6">
|
||||
<Input
|
||||
v-model="folderNameLocal"
|
||||
:label="t('FILTER.FOLDER_LABEL')"
|
||||
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
<div
|
||||
v-on-click-outside="viewInModal ? [] : outsideClickHandler"
|
||||
class="z-40 max-w-3xl xl:w-[750px] w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl py-6 grid gap-6"
|
||||
:class="{ 'overflow-x-auto': viewInModal }"
|
||||
>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12 px-6">
|
||||
{{ filterModalHeaderTitle }}
|
||||
</h3>
|
||||
<div v-if="props.isFolderView" class="px-6">
|
||||
<div class="border-b border-n-strong pb-6">
|
||||
<Input
|
||||
v-model="folderNameLocal"
|
||||
:label="t('FILTER.FOLDER_LABEL')"
|
||||
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="grid gap-4 list-none">
|
||||
<template v-for="(filter, index) in filters" :key="filter.id">
|
||||
<ConditionRow
|
||||
v-if="index === 0"
|
||||
ref="conditionsRef"
|
||||
:key="`filter-${filter.attributeKey}-0`"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
:key="`filter-${filter.attributeKey}-${index}`"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
v-model:values="filter.values"
|
||||
show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="flex gap-2 justify-between">
|
||||
<Button sm ghost blue @click="addFilter">
|
||||
{{ $t('FILTER.ADD_NEW_FILTER') }}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button sm faded slate @click="resetFilter">
|
||||
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isFolderView"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:disabled="!folderNameLocal"
|
||||
@click="updateSavedCustomViews"
|
||||
>
|
||||
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
<Button v-else sm solid blue @click="validateAndSubmit">
|
||||
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
|
||||
<ul class="grid gap-4 list-none px-6">
|
||||
<template v-for="(filter, index) in filters" :key="filter.id">
|
||||
<ConditionRow
|
||||
v-if="index === 0"
|
||||
ref="conditionsRef"
|
||||
:key="`filter-${filter.attributeKey}-0`"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
:key="`filter-${filter.attributeKey}-${index}`"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
v-model:values="filter.values"
|
||||
show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
<div
|
||||
class="flex gap-2 justify-between ltr:pl-3 ltr:pr-6 rtl:pr-3 rtl:pl-6"
|
||||
>
|
||||
<Button sm ghost blue class="px-0" @click="addFilter">
|
||||
{{ $t('FILTER.ADD_NEW_FILTER') }}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button sm solid slate @click="resetFilter">
|
||||
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isFolderView"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:disabled="!folderNameLocal"
|
||||
@click="updateSavedCustomViews"
|
||||
>
|
||||
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
<Button v-else sm solid blue @click="validateAndSubmit">
|
||||
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,8 +108,10 @@ export default {
|
||||
<NextInput
|
||||
v-model="name"
|
||||
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
|
||||
:message="v$.name.$error && $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE')"
|
||||
:message-type="v$.name.$error && 'error'"
|
||||
:message="
|
||||
v$.name.$error ? $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') : ''
|
||||
"
|
||||
:message-type="v$.name.$error ? 'error' : 'info'"
|
||||
@blur="v$.name.$touch"
|
||||
/>
|
||||
<div class="flex flex-row justify-end w-full gap-2">
|
||||
|
||||
@@ -95,7 +95,7 @@ const toggleOption = option => {
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
v-if="hasItems"
|
||||
class="bg-n-alpha-2 py-2 rounded-lg h-8 flex items-center px-0"
|
||||
class="bg-n-button-color outline outline-1 outline-n-container py-2 rounded-lg h-8 flex items-center px-0"
|
||||
@click="toggle"
|
||||
>
|
||||
<div
|
||||
@@ -119,11 +119,13 @@ const toggleOption = option => {
|
||||
<Icon icon="i-lucide-plus" />
|
||||
</div>
|
||||
</button>
|
||||
<Button v-else sm slate faded @click="toggle">
|
||||
<Button v-else sm slate solid @click="toggle">
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
|
||||
<Icon icon="i-lucide-plus" class="text-n-slate-11 flex-shrink-0" />
|
||||
</template>
|
||||
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
|
||||
<span class="text-n-slate-11 truncate">{{
|
||||
t('COMBOBOX.PLACEHOLDER')
|
||||
}}</span>
|
||||
</Button>
|
||||
</template>
|
||||
<DropdownBody class="top-0 min-w-48 z-50" strong>
|
||||
|
||||
@@ -1,77 +1,34 @@
|
||||
<script setup>
|
||||
// [TODO] This componet is too big and bulky to be in the same file, we can consider splitting this into multiple
|
||||
// composables and components, useVirtualChatList, useChatlistFilters
|
||||
import {
|
||||
ref,
|
||||
unref,
|
||||
provide,
|
||||
computed,
|
||||
watch,
|
||||
onMounted,
|
||||
defineEmits,
|
||||
} from 'vue';
|
||||
import { ref, provide, computed, watch, onMounted, defineEmits } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
useMapGetter,
|
||||
useFunctionGetter,
|
||||
} from 'dashboard/composables/store.js';
|
||||
|
||||
// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
|
||||
// from the current one. But we should consider using tanstack virtual in the future
|
||||
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||
import SaveCustomView from 'next/filter/SaveCustomView.vue';
|
||||
import ConversationList from './ConversationList.vue';
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import IntersectionObserver from './IntersectionObserver.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
import ChatListFilters from './ChatListFilters.vue';
|
||||
import ConversationActiveFiltersPreview from './widgets/conversation/ConversationActiveFiltersPreview.vue';
|
||||
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
|
||||
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
|
||||
import { useFilter } from 'shared/composables/useFilter';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
useCamelCase,
|
||||
useSnakeCase,
|
||||
} from 'dashboard/composables/useTransformKeys';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
|
||||
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
|
||||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
import countries from 'shared/constants/countries';
|
||||
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
||||
import { conversationListPageURL } from '../helper/URLHelper';
|
||||
import {
|
||||
isOnMentionsView,
|
||||
isOnUnattendedView,
|
||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||
import {
|
||||
getUserPermissions,
|
||||
filterItemsByPermission,
|
||||
} from 'dashboard/helper/permissionsHelper.js';
|
||||
import { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers';
|
||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
||||
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
|
||||
const props = defineProps({
|
||||
conversationInbox: { type: [String, Number], default: 0 },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
@@ -85,34 +42,15 @@ const props = defineProps({
|
||||
const emit = defineEmits(['conversationLoad']);
|
||||
const { uiSettings } = useUISettings();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
const conversationListRef = ref(null);
|
||||
const conversationDynamicScroller = ref(null);
|
||||
|
||||
provide('contextMenuElementTarget', conversationDynamicScroller);
|
||||
const router = useRouter();
|
||||
|
||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||
const activeSortBy = ref(wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC);
|
||||
const showAdvancedFilters = ref(false);
|
||||
// chatsOnView is to store the chats that are currently visible on the screen,
|
||||
// which mirrors the conversationList.
|
||||
const chatsOnView = ref([]);
|
||||
const foldersQuery = ref({});
|
||||
const showAddFoldersModal = ref(false);
|
||||
const showDeleteFoldersModal = ref(false);
|
||||
const isContextMenuOpen = ref(false);
|
||||
const appliedFilter = ref([]);
|
||||
const advancedFilterTypes = ref(
|
||||
advancedFilterOptions.map(filter => ({
|
||||
...filter,
|
||||
attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
|
||||
}))
|
||||
);
|
||||
const chatListFiltersRef = ref(null);
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const chatLists = useMapGetter('getFilteredConversations');
|
||||
@@ -123,18 +61,10 @@ const chatListLoading = useMapGetter('getChatListLoadingStatus');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const conversationStats = useMapGetter('conversationStats/getStats');
|
||||
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
|
||||
const folders = useMapGetter('customViews/getConversationCustomViews');
|
||||
const agentList = useMapGetter('agents/getAgents');
|
||||
const teamsList = useMapGetter('teams/getTeams');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
// We can't useFunctionGetter here since it needs to be called on setup?
|
||||
const getTeamFn = useMapGetter('teams/getTeam');
|
||||
const getConversationById = useMapGetter('getConversationById');
|
||||
const folders = useMapGetter('customViews/getConversationCustomViews');
|
||||
|
||||
useChatListKeyboardEvents(conversationListRef);
|
||||
const {
|
||||
selectedConversations,
|
||||
selectedInboxes,
|
||||
@@ -143,63 +73,28 @@ const {
|
||||
selectAllConversations,
|
||||
resetBulkActions,
|
||||
isConversationSelected,
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onRemoveLabels,
|
||||
onAssignTeamsForBulk,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
|
||||
const {
|
||||
initializeStatusAndAssigneeFilterToModal,
|
||||
initializeInboxTeamAndLabelFilterToModal,
|
||||
} = useFilter({
|
||||
filteri18nKey: 'FILTER',
|
||||
attributeModel: 'conversation_attribute',
|
||||
});
|
||||
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
// computed
|
||||
const intersectionObserverOptions = computed(() => {
|
||||
return {
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
};
|
||||
});
|
||||
|
||||
const hasAppliedFilters = computed(() => {
|
||||
return appliedFilters.value.length !== 0;
|
||||
});
|
||||
|
||||
const activeFolder = computed(() => {
|
||||
if (props.foldersId) {
|
||||
const activeView = folders.value.filter(
|
||||
view => view.id === Number(props.foldersId)
|
||||
);
|
||||
const [firstValue] = activeView;
|
||||
return firstValue;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const activeFolderName = computed(() => {
|
||||
return activeFolder.value?.name;
|
||||
});
|
||||
|
||||
const hasActiveFolders = computed(() => {
|
||||
return Boolean(activeFolder.value && props.foldersId !== 0);
|
||||
return Boolean(props.foldersId !== 0);
|
||||
});
|
||||
|
||||
const activeFolder = computed(() => {
|
||||
return chatListFiltersRef.value?.activeFolder;
|
||||
});
|
||||
|
||||
const isFilterModalOpen = computed(() => {
|
||||
return chatListFiltersRef.value?.showAdvancedFilters ?? false;
|
||||
});
|
||||
|
||||
const hasAppliedFiltersOrActiveFolders = computed(() => {
|
||||
return hasAppliedFilters.value || hasActiveFolders.value;
|
||||
});
|
||||
|
||||
const currentUserDetails = computed(() => {
|
||||
const { id, name } = currentUser.value;
|
||||
return { id, name };
|
||||
});
|
||||
|
||||
const userPermissions = computed(() => {
|
||||
return getUserPermissions(currentUser.value, currentAccountId.value);
|
||||
});
|
||||
@@ -243,11 +138,6 @@ const hasCurrentPageEndReached = useFunctionGetter(
|
||||
currentPageFilterKey
|
||||
);
|
||||
|
||||
const conversationCustomAttributes = useFunctionGetter(
|
||||
'attributes/getAttributesByModel',
|
||||
'conversation_attribute'
|
||||
);
|
||||
|
||||
const activeAssigneeTabCount = computed(() => {
|
||||
const count = assigneeTabItems.value.find(
|
||||
item => item.key === activeAssigneeTab.value
|
||||
@@ -317,7 +207,7 @@ const pageTitle = computed(() => {
|
||||
if (props.conversationType === 'unattended') {
|
||||
return t('CHAT_LIST.UNATTENDED_HEADING');
|
||||
}
|
||||
if (hasActiveFolders.value) {
|
||||
if (hasActiveFolders.value && activeFolder.value?.name) {
|
||||
return activeFolder.value.name;
|
||||
}
|
||||
return t('CHAT_LIST.TAB_HEADING');
|
||||
@@ -339,7 +229,7 @@ const conversationList = computed(() => {
|
||||
localConversationList = [...chatLists.value];
|
||||
}
|
||||
|
||||
if (activeFolder.value) {
|
||||
if (activeFolder.value?.query?.payload) {
|
||||
const { payload } = activeFolder.value.query;
|
||||
localConversationList = localConversationList.filter(conversation => {
|
||||
return matchesFilters(conversation, payload);
|
||||
@@ -350,7 +240,7 @@ const conversationList = computed(() => {
|
||||
});
|
||||
|
||||
const showEndOfListMessage = computed(() => {
|
||||
return (
|
||||
return !!(
|
||||
conversationList.value.length &&
|
||||
hasCurrentPageEndReached.value &&
|
||||
!chatListLoading.value
|
||||
@@ -384,205 +274,71 @@ function setFiltersFromUISettings() {
|
||||
|
||||
function emitConversationLoaded() {
|
||||
emit('conversationLoad');
|
||||
// [VITE] removing this since the library has changed
|
||||
// nextTick(() => {
|
||||
// // Addressing a known issue in the virtual list library where dynamically added items
|
||||
// // might not render correctly. This workaround involves a slight manual adjustment
|
||||
// // to the scroll position, triggering the list to refresh its rendering.
|
||||
// const virtualList = conversationListRef.value;
|
||||
// const scrollToOffset = virtualList?.scrollToOffset;
|
||||
// const currentOffset = virtualList?.getOffset() || 0;
|
||||
// if (scrollToOffset) {
|
||||
// scrollToOffset(currentOffset + 1);
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
function fetchFilteredConversations(payload) {
|
||||
payload = useSnakeCase(payload);
|
||||
const transformedPayload = useSnakeCase(payload);
|
||||
let page = currentFiltersPage.value + 1;
|
||||
store
|
||||
.dispatch('fetchFilteredConversations', {
|
||||
queryData: filterQueryGenerator(payload),
|
||||
queryData: filterQueryGenerator(transformedPayload),
|
||||
page,
|
||||
})
|
||||
.then(emitConversationLoaded);
|
||||
|
||||
showAdvancedFilters.value = false;
|
||||
}
|
||||
|
||||
function fetchSavedFilteredConversations(payload) {
|
||||
payload = useSnakeCase(payload);
|
||||
const transformedPayload = useSnakeCase(payload);
|
||||
let page = currentFiltersPage.value + 1;
|
||||
store
|
||||
.dispatch('fetchFilteredConversations', {
|
||||
queryData: payload,
|
||||
queryData: transformedPayload,
|
||||
page,
|
||||
})
|
||||
.then(emitConversationLoaded);
|
||||
}
|
||||
|
||||
function onApplyFilter(payload) {
|
||||
payload = useSnakeCase(payload);
|
||||
resetBulkActions();
|
||||
foldersQuery.value = filterQueryGenerator(payload);
|
||||
store.dispatch('conversationPage/reset');
|
||||
store.dispatch('emptyAllConversations');
|
||||
fetchFilteredConversations(payload);
|
||||
}
|
||||
|
||||
function closeAdvanceFiltersModal() {
|
||||
showAdvancedFilters.value = false;
|
||||
appliedFilter.value = [];
|
||||
}
|
||||
|
||||
function onUpdateSavedFilter(payload, folderName) {
|
||||
const transformedPayload = useSnakeCase(payload);
|
||||
const payloadData = {
|
||||
...unref(activeFolder),
|
||||
name: unref(folderName),
|
||||
query: filterQueryGenerator(transformedPayload),
|
||||
};
|
||||
store.dispatch('customViews/update', payloadData);
|
||||
closeAdvanceFiltersModal();
|
||||
}
|
||||
|
||||
function onClickOpenAddFoldersModal() {
|
||||
showAddFoldersModal.value = true;
|
||||
}
|
||||
|
||||
function onCloseAddFoldersModal() {
|
||||
showAddFoldersModal.value = false;
|
||||
}
|
||||
|
||||
function onClickOpenDeleteFoldersModal() {
|
||||
showDeleteFoldersModal.value = true;
|
||||
}
|
||||
|
||||
function onCloseDeleteFoldersModal() {
|
||||
showDeleteFoldersModal.value = false;
|
||||
}
|
||||
|
||||
function setParamsForEditFolderModal() {
|
||||
// Here we are setting the params for edit folder modal to show the existing values.
|
||||
|
||||
// For agent, team, inboxes,and campaigns we get only the id's from the query.
|
||||
// So we are mapping the id's to the actual values.
|
||||
|
||||
// For labels we get the name of the label from the query.
|
||||
// If we delete the label from the label list then we will not be able to show the label name.
|
||||
|
||||
// For custom attributes we get only attribute key.
|
||||
// So we are mapping it to find the input type of the attribute to show in the edit folder modal.
|
||||
return {
|
||||
agents: agentList.value,
|
||||
teams: teamsList.value,
|
||||
inboxes: inboxesList.value,
|
||||
labels: labels.value,
|
||||
campaigns: campaigns.value,
|
||||
languages: languages,
|
||||
countries: countries,
|
||||
priority: [
|
||||
{ id: 'low', name: t('CONVERSATION.PRIORITY.OPTIONS.LOW') },
|
||||
{ id: 'medium', name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM') },
|
||||
{ id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
|
||||
{ id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
|
||||
],
|
||||
filterTypes: advancedFilterTypes.value,
|
||||
allCustomAttributes: conversationCustomAttributes.value,
|
||||
};
|
||||
}
|
||||
|
||||
function initializeExistingFilterToModal() {
|
||||
const statusFilter = initializeStatusAndAssigneeFilterToModal(
|
||||
activeStatus.value,
|
||||
currentUserDetails.value,
|
||||
activeAssigneeTab.value
|
||||
);
|
||||
// TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
|
||||
if (statusFilter) {
|
||||
appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)];
|
||||
}
|
||||
|
||||
// TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
|
||||
const otherFilters = initializeInboxTeamAndLabelFilterToModal(
|
||||
props.conversationInbox,
|
||||
inbox.value,
|
||||
props.teamId,
|
||||
activeTeam.value,
|
||||
props.label
|
||||
).map(useCamelCase);
|
||||
|
||||
appliedFilter.value = [...appliedFilter.value, ...otherFilters];
|
||||
}
|
||||
|
||||
function initializeFolderToFilterModal(newActiveFolder) {
|
||||
// Here we are setting the params for edit folder modal.
|
||||
// To show the existing values. when we click on edit folder button.
|
||||
|
||||
// Here we get the query from the active folder.
|
||||
// And we are mapping the query to the actual values.
|
||||
// To show in the edit folder modal by the help of generateValuesForEditCustomViews helper.
|
||||
const query = unref(newActiveFolder)?.query?.payload;
|
||||
if (!Array.isArray(query)) return;
|
||||
|
||||
const newFilters = query.map(filter => {
|
||||
const transformed = useCamelCase(filter);
|
||||
const values = Array.isArray(transformed.values)
|
||||
? generateValuesForEditCustomViews(
|
||||
useSnakeCase(filter),
|
||||
setParamsForEditFolderModal()
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
attributeKey: transformed.attributeKey,
|
||||
attributeModel: transformed.attributeModel,
|
||||
customAttributeType: transformed.customAttributeType,
|
||||
filterOperator: transformed.filterOperator,
|
||||
queryOperator: transformed.queryOperator ?? 'and',
|
||||
values,
|
||||
};
|
||||
});
|
||||
|
||||
appliedFilter.value = [...appliedFilter.value, ...newFilters];
|
||||
}
|
||||
|
||||
function initalizeAppliedFiltersToModal() {
|
||||
appliedFilter.value = [...appliedFilters.value];
|
||||
}
|
||||
|
||||
function onToggleAdvanceFiltersModal() {
|
||||
if (showAdvancedFilters.value === true) {
|
||||
closeAdvanceFiltersModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAppliedFilters.value && !hasActiveFolders.value) {
|
||||
initializeExistingFilterToModal();
|
||||
}
|
||||
if (hasActiveFolders.value) {
|
||||
initializeFolderToFilterModal(activeFolder.value);
|
||||
}
|
||||
if (hasAppliedFilters.value) {
|
||||
initalizeAppliedFiltersToModal();
|
||||
}
|
||||
|
||||
showAdvancedFilters.value = true;
|
||||
}
|
||||
|
||||
function fetchConversations() {
|
||||
store.dispatch('updateChatListFilters', conversationFilters.value);
|
||||
store.dispatch('fetchAllConversations').then(emitConversationLoaded);
|
||||
}
|
||||
|
||||
function onApplyFilter(payload) {
|
||||
resetBulkActions();
|
||||
store.dispatch('conversationPage/reset');
|
||||
store.dispatch('emptyAllConversations');
|
||||
fetchFilteredConversations(payload);
|
||||
}
|
||||
|
||||
function onOpenAddFolders() {
|
||||
const lastItemOfFolder = folders.value[folders.value.length - 1];
|
||||
const lastItemId = lastItemOfFolder.id;
|
||||
router.push({
|
||||
name: 'folder_conversations',
|
||||
params: { id: lastItemId },
|
||||
});
|
||||
}
|
||||
|
||||
function onOpenDeleteFolders() {
|
||||
if (folders.value.length > 0) {
|
||||
const lastItemOfFolder = folders.value[folders.value.length - 1];
|
||||
const lastItemId = lastItemOfFolder.id;
|
||||
router.push({
|
||||
name: 'folder_conversations',
|
||||
params: { id: lastItemId },
|
||||
});
|
||||
} else {
|
||||
router.push({ name: 'home' });
|
||||
fetchConversations();
|
||||
}
|
||||
}
|
||||
|
||||
function resetAndFetchData() {
|
||||
appliedFilter.value = [];
|
||||
resetBulkActions();
|
||||
store.dispatch('conversationPage/reset');
|
||||
store.dispatch('emptyAllConversations');
|
||||
store.dispatch('clearConversationFilters');
|
||||
if (hasActiveFolders.value) {
|
||||
if (hasActiveFolders.value && activeFolder.value?.query) {
|
||||
const payload = activeFolder.value.query;
|
||||
fetchSavedFilteredConversations(payload);
|
||||
}
|
||||
@@ -599,7 +355,7 @@ function loadMoreConversations() {
|
||||
|
||||
if (!hasAppliedFiltersOrActiveFolders.value) {
|
||||
fetchConversations();
|
||||
} else if (hasActiveFolders.value) {
|
||||
} else if (hasActiveFolders.value && activeFolder.value?.query) {
|
||||
const payload = activeFolder.value.query;
|
||||
fetchSavedFilteredConversations(payload);
|
||||
} else if (hasAppliedFilters.value) {
|
||||
@@ -607,17 +363,6 @@ function loadMoreConversations() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add a method to handle scroll events
|
||||
function handleScroll() {
|
||||
const scroller = conversationDynamicScroller.value;
|
||||
if (scroller && scroller.hasScrollbar) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
|
||||
if (scrollHeight - (scrollTop + clientHeight) < 100) {
|
||||
loadMoreConversations();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssigneeTab(selectedTab) {
|
||||
if (activeAssigneeTab.value !== selectedTab) {
|
||||
resetBulkActions();
|
||||
@@ -638,174 +383,10 @@ function onBasicFilterChange(value, type) {
|
||||
resetAndFetchData();
|
||||
}
|
||||
|
||||
function openLastSavedItemInFolder() {
|
||||
const lastItemOfFolder = folders.value[folders.value.length - 1];
|
||||
const lastItemId = lastItemOfFolder.id;
|
||||
router.push({
|
||||
name: 'folder_conversations',
|
||||
params: { id: lastItemId },
|
||||
});
|
||||
}
|
||||
|
||||
function openLastItemAfterDeleteInFolder() {
|
||||
if (folders.value.length > 0) {
|
||||
openLastSavedItemInFolder();
|
||||
} else {
|
||||
router.push({ name: 'home' });
|
||||
fetchConversations();
|
||||
}
|
||||
}
|
||||
|
||||
function redirectToConversationList() {
|
||||
const {
|
||||
params: { accountId, inbox_id: inboxId, label, teamId },
|
||||
name,
|
||||
} = route;
|
||||
|
||||
let conversationType = '';
|
||||
if (isOnMentionsView({ route: { name } })) {
|
||||
conversationType = 'mention';
|
||||
} else if (isOnUnattendedView({ route: { name } })) {
|
||||
conversationType = 'unattended';
|
||||
}
|
||||
router.push(
|
||||
conversationListPageURL({
|
||||
accountId,
|
||||
conversationType: conversationType,
|
||||
customViewId: props.foldersId,
|
||||
inboxId,
|
||||
label,
|
||||
teamId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function assignPriority(priority, conversationId = null) {
|
||||
store.dispatch('setCurrentChatPriority', {
|
||||
priority,
|
||||
conversationId,
|
||||
});
|
||||
store.dispatch('assignPriority', { conversationId, priority }).then(() => {
|
||||
useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
|
||||
newValue: priority,
|
||||
from: 'Context menu',
|
||||
});
|
||||
useAlert(
|
||||
t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
|
||||
priority,
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function markAsUnread(conversationId) {
|
||||
try {
|
||||
await store.dispatch('markMessagesUnread', {
|
||||
id: conversationId,
|
||||
});
|
||||
redirectToConversationList();
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
async function markAsRead(conversationId) {
|
||||
try {
|
||||
await store.dispatch('markMessagesRead', {
|
||||
id: conversationId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
|
||||
async function onAssignTeam(team, conversationId = null) {
|
||||
try {
|
||||
await store.dispatch('assignTeam', {
|
||||
conversationId,
|
||||
teamId: team.id,
|
||||
});
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.SUCCESFUL', {
|
||||
team: team.name,
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.FAILED'));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleConversationStatus(
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
customAttributes = null
|
||||
) {
|
||||
const payload = {
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
};
|
||||
|
||||
if (customAttributes) {
|
||||
payload.customAttributes = customAttributes;
|
||||
}
|
||||
|
||||
store.dispatch('toggleStatus', payload).then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
});
|
||||
}
|
||||
|
||||
function handleResolveConversation(conversationId, status, snoozedUntil) {
|
||||
if (status !== wootConstants.STATUS_TYPE.RESOLVED) {
|
||||
toggleConversationStatus(conversationId, status, snoozedUntil);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for required attributes before resolving
|
||||
const conversation = getConversationById.value(conversationId);
|
||||
const currentCustomAttributes = conversation?.custom_attributes || {};
|
||||
const { hasMissing, missing } = checkMissingAttributes(
|
||||
currentCustomAttributes
|
||||
);
|
||||
|
||||
if (hasMissing) {
|
||||
// Pass conversation context through the modal's API
|
||||
const conversationContext = {
|
||||
id: conversationId,
|
||||
snoozedUntil,
|
||||
};
|
||||
resolveAttributesModalRef.value?.open(
|
||||
missing,
|
||||
currentCustomAttributes,
|
||||
conversationContext
|
||||
);
|
||||
} else {
|
||||
toggleConversationStatus(conversationId, status, snoozedUntil);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResolveWithAttributes({ attributes, context }) {
|
||||
if (context) {
|
||||
const existingConversation = getConversationById.value(context.id);
|
||||
const currentCustomAttributes =
|
||||
existingConversation?.custom_attributes || {};
|
||||
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
|
||||
|
||||
toggleConversationStatus(
|
||||
context.id,
|
||||
wootConstants.STATUS_TYPE.RESOLVED,
|
||||
context.snoozedUntil,
|
||||
mergedAttributes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function allSelectedConversationsStatus(status) {
|
||||
if (!selectedConversations.value.length) return false;
|
||||
return selectedConversations.value.every(item => {
|
||||
return getConversationById.value(item)?.status === status;
|
||||
return store.getters.getConversationById(item)?.status === status;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -822,8 +403,6 @@ useEmitter('fetch_conversation_stats', () => {
|
||||
store.dispatch('conversationStats/get', conversationFilters.value);
|
||||
});
|
||||
|
||||
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('setChatListFilters', conversationFilters.value);
|
||||
setFiltersFromUISettings();
|
||||
@@ -835,46 +414,19 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const deleteConversationDialogRef = ref(null);
|
||||
const selectedConversationId = ref(null);
|
||||
|
||||
async function deleteConversation() {
|
||||
try {
|
||||
await store.dispatch('deleteConversation', selectedConversationId.value);
|
||||
redirectToConversationList();
|
||||
selectedConversationId.value = null;
|
||||
deleteConversationDialogRef.value.close();
|
||||
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = conversationId => {
|
||||
selectedConversationId.value = conversationId;
|
||||
deleteConversationDialogRef.value.open();
|
||||
};
|
||||
|
||||
provide('selectConversation', selectConversation);
|
||||
provide('deSelectConversation', deSelectConversation);
|
||||
provide('assignAgent', onAssignAgent);
|
||||
provide('assignTeam', onAssignTeam);
|
||||
provide('assignLabels', onAssignLabels);
|
||||
provide('removeLabels', onRemoveLabels);
|
||||
provide('updateConversationStatus', handleResolveConversation);
|
||||
provide('toggleContextMenu', onContextMenuToggle);
|
||||
provide('markAsUnread', markAsUnread);
|
||||
provide('markAsRead', markAsRead);
|
||||
provide('assignPriority', assignPriority);
|
||||
provide('isConversationSelected', isConversationSelected);
|
||||
provide('deleteConversation', handleDelete);
|
||||
|
||||
watch(activeTeam, () => resetAndFetchData());
|
||||
|
||||
watch(
|
||||
computed(() => props.conversationInbox),
|
||||
() => resetAndFetchData()
|
||||
);
|
||||
watch(
|
||||
computed(() => props.teamId),
|
||||
() => resetAndFetchData()
|
||||
);
|
||||
watch(
|
||||
computed(() => props.label),
|
||||
() => resetAndFetchData()
|
||||
@@ -883,6 +435,10 @@ watch(
|
||||
computed(() => props.conversationType),
|
||||
() => resetAndFetchData()
|
||||
);
|
||||
watch(
|
||||
computed(() => props.foldersId),
|
||||
() => resetAndFetchData()
|
||||
);
|
||||
|
||||
watch(activeFolder, (newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
@@ -904,7 +460,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1"
|
||||
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1 relative"
|
||||
:class="[
|
||||
{ hidden: !showConversationList },
|
||||
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
|
||||
@@ -919,32 +475,25 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
:conversation-stats="conversationStats"
|
||||
:is-list-loading="chatListLoading && !conversationList.length"
|
||||
@add-folders="onClickOpenAddFoldersModal"
|
||||
@delete-folders="onClickOpenDeleteFoldersModal"
|
||||
@filters-modal="onToggleAdvanceFiltersModal"
|
||||
@reset-filters="resetAndFetchData"
|
||||
:is-filter-modal-open="isFilterModalOpen"
|
||||
@filters-modal="chatListFiltersRef?.onToggleAdvanceFiltersModal"
|
||||
@basic-filter-change="onBasicFilterChange"
|
||||
/>
|
||||
|
||||
<TeleportWithDirection
|
||||
v-if="showAddFoldersModal"
|
||||
to="#saveFilterTeleportTarget"
|
||||
>
|
||||
<SaveCustomView
|
||||
v-model="appliedFilter"
|
||||
:custom-views-query="foldersQuery"
|
||||
:open-last-saved-item="openLastSavedItemInFolder"
|
||||
@close="onCloseAddFoldersModal"
|
||||
/>
|
||||
</TeleportWithDirection>
|
||||
|
||||
<DeleteCustomViews
|
||||
v-if="showDeleteFoldersModal"
|
||||
v-model:show="showDeleteFoldersModal"
|
||||
:active-custom-view="activeFolder"
|
||||
:custom-views-id="foldersId"
|
||||
:open-last-item-after-delete="openLastItemAfterDeleteInFolder"
|
||||
@close="onCloseDeleteFoldersModal"
|
||||
<ChatListFilters
|
||||
ref="chatListFiltersRef"
|
||||
:conversation-inbox="conversationInbox"
|
||||
:team-id="teamId"
|
||||
:label="label"
|
||||
:folders-id="foldersId"
|
||||
:active-status="activeStatus"
|
||||
:active-assignee-tab="activeAssigneeTab"
|
||||
:current-user-details="{ id: currentUser.id, name: currentUser.name }"
|
||||
:has-active-folders="hasActiveFolders"
|
||||
:has-applied-filters="hasAppliedFilters"
|
||||
@apply-filter="onApplyFilter"
|
||||
@open-add-folders="onOpenAddFolders"
|
||||
@open-delete-folders="onOpenDeleteFolders"
|
||||
/>
|
||||
|
||||
<ChatTypeTabs
|
||||
@@ -954,114 +503,45 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
is-compact
|
||||
@chat-tab-change="updateAssigneeTab"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="!chatListLoading && !conversationList.length"
|
||||
class="flex overflow-auto justify-center items-center p-4"
|
||||
>
|
||||
{{ $t('CHAT_LIST.LIST.404') }}
|
||||
</p>
|
||||
<ConversationActiveFiltersPreview
|
||||
:active-folder="activeFolder"
|
||||
:has-active-folders="hasActiveFolders"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@clear-filters="resetAndFetchData"
|
||||
@open-filter="chatListFiltersRef?.onToggleAdvanceFiltersModal"
|
||||
@save-folder="chatListFiltersRef?.onClickOpenAddFoldersModal"
|
||||
@delete-folder="chatListFiltersRef?.onClickOpenDeleteFoldersModal"
|
||||
/>
|
||||
<ConversationBulkActions
|
||||
v-if="selectedConversations.length"
|
||||
:conversations="selectedConversations"
|
||||
:all-conversations-selected="allConversationsSelected"
|
||||
:selected-inboxes="uniqueInboxes"
|
||||
:show-open-action="allSelectedConversationsStatus('open')"
|
||||
:show-resolved-action="allSelectedConversationsStatus('resolved')"
|
||||
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
|
||||
:class="isOnExpandedLayout && 'sm:!w-[24rem] !w-full'"
|
||||
@select-all-conversations="toggleSelectAll"
|
||||
@assign-agent="onAssignAgent"
|
||||
@update-conversations="onUpdateConversations"
|
||||
@assign-labels="onAssignLabels"
|
||||
@assign-team="onAssignTeamsForBulk"
|
||||
/>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
|
||||
<p
|
||||
v-if="!chatListLoading && !conversationList.length"
|
||||
class="flex items-center justify-center p-4 overflow-auto"
|
||||
>
|
||||
<DynamicScroller
|
||||
ref="conversationDynamicScroller"
|
||||
:items="conversationList"
|
||||
:min-item-size="24"
|
||||
class="overflow-auto w-full h-full"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<!--
|
||||
If we encounter resizing issues, we can set the `watchData` prop to true
|
||||
this will deeply watch the entire object instead of just size dependencies
|
||||
But it can impact performance
|
||||
-->
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:data-index="index"
|
||||
:size-dependencies="[
|
||||
item.messages,
|
||||
item.labels,
|
||||
item.uuid,
|
||||
item.inbox_id,
|
||||
]"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
<template #after>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</div>
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
type="alert"
|
||||
:title="
|
||||
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
|
||||
conversationId: selectedConversationId,
|
||||
})
|
||||
"
|
||||
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
|
||||
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
|
||||
@confirm="deleteConversation"
|
||||
@close="selectedConversationId = null"
|
||||
/>
|
||||
<TeleportWithDirection
|
||||
v-if="showAdvancedFilters"
|
||||
to="#conversationFilterTeleportTarget"
|
||||
>
|
||||
<ConversationFilter
|
||||
v-model="appliedFilter"
|
||||
:folder-name="activeFolderName"
|
||||
:is-folder-view="hasActiveFolders"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-folder="onUpdateSavedFilter"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
/>
|
||||
</TeleportWithDirection>
|
||||
<ConversationResolveAttributesModal
|
||||
ref="resolveAttributesModalRef"
|
||||
@submit="handleResolveWithAttributes"
|
||||
{{ $t('CHAT_LIST.LIST.404') }}
|
||||
</p>
|
||||
|
||||
<ConversationList
|
||||
:items="conversationList"
|
||||
:is-loading="chatListLoading"
|
||||
:show-end-of-list-message="showEndOfListMessage"
|
||||
:is-context-menu-open="isContextMenuOpen"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:is-expanded-layout="isOnExpandedLayout"
|
||||
@load-more="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
<script setup>
|
||||
import { ref, unref, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
useMapGetter,
|
||||
useFunctionGetter,
|
||||
} from 'dashboard/composables/store.js';
|
||||
import {
|
||||
useCamelCase,
|
||||
useSnakeCase,
|
||||
} from 'dashboard/composables/useTransformKeys';
|
||||
import { useFilter } from 'shared/composables/useFilter';
|
||||
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||
import SaveCustomView from 'next/filter/SaveCustomView.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
|
||||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||
import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
import countries from 'shared/constants/countries';
|
||||
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
||||
|
||||
const props = defineProps({
|
||||
conversationInbox: { type: [String, Number], default: 0 },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
label: { type: String, default: '' },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
activeStatus: { type: String, required: true },
|
||||
activeAssigneeTab: { type: String, required: true },
|
||||
currentUserDetails: { type: Object, required: true },
|
||||
hasActiveFolders: { type: Boolean, default: false },
|
||||
hasAppliedFilters: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'openAddFolders',
|
||||
'openDeleteFolders',
|
||||
]);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const showAdvancedFilters = ref(false);
|
||||
const showAddFoldersModal = ref(false);
|
||||
const showDeleteFoldersModal = ref(false);
|
||||
const appliedFilter = ref([]);
|
||||
const foldersQuery = ref({});
|
||||
|
||||
const advancedFilterTypes = ref(
|
||||
advancedFilterOptions.map(filter => ({
|
||||
...filter,
|
||||
attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const folders = useMapGetter('customViews/getConversationCustomViews');
|
||||
const agentList = useMapGetter('agents/getAgents');
|
||||
const teamsList = useMapGetter('teams/getTeams');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const getTeamFn = useMapGetter('teams/getTeam');
|
||||
|
||||
const inbox = useFunctionGetter('inboxes/getInbox', activeInbox);
|
||||
const conversationCustomAttributes = useFunctionGetter(
|
||||
'attributes/getAttributesByModel',
|
||||
'conversation_attribute'
|
||||
);
|
||||
|
||||
const activeFolder = computed(() => {
|
||||
if (props.foldersId) {
|
||||
const activeView = folders.value.filter(
|
||||
view => view.id === Number(props.foldersId)
|
||||
);
|
||||
const [firstValue] = activeView;
|
||||
return firstValue;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const activeFolderName = computed(() => {
|
||||
return activeFolder.value?.name;
|
||||
});
|
||||
|
||||
const activeTeam = computed(() => {
|
||||
if (props.teamId) {
|
||||
return getTeamFn.value(props.teamId);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const {
|
||||
initializeStatusAndAssigneeFilterToModal,
|
||||
initializeInboxTeamAndLabelFilterToModal,
|
||||
} = useFilter({
|
||||
filteri18nKey: 'FILTER',
|
||||
attributeModel: 'conversation_attribute',
|
||||
});
|
||||
|
||||
function closeAdvanceFiltersModal() {
|
||||
showAdvancedFilters.value = false;
|
||||
appliedFilter.value = [];
|
||||
}
|
||||
|
||||
function onApplyFilter(payload) {
|
||||
const transformedPayload = useSnakeCase(payload);
|
||||
foldersQuery.value = filterQueryGenerator(transformedPayload);
|
||||
emit('applyFilter', transformedPayload);
|
||||
showAdvancedFilters.value = false;
|
||||
}
|
||||
|
||||
function onUpdateSavedFilter(payload, folderName) {
|
||||
const transformedPayload = useSnakeCase(payload);
|
||||
const payloadData = {
|
||||
...unref(activeFolder),
|
||||
name: unref(folderName),
|
||||
query: filterQueryGenerator(transformedPayload),
|
||||
};
|
||||
store.dispatch('customViews/update', payloadData);
|
||||
closeAdvanceFiltersModal();
|
||||
}
|
||||
|
||||
function onClickOpenAddFoldersModal() {
|
||||
showAddFoldersModal.value = true;
|
||||
}
|
||||
|
||||
function onCloseAddFoldersModal() {
|
||||
showAddFoldersModal.value = false;
|
||||
}
|
||||
|
||||
function onClickOpenDeleteFoldersModal() {
|
||||
showDeleteFoldersModal.value = true;
|
||||
}
|
||||
|
||||
function onCloseDeleteFoldersModal() {
|
||||
showDeleteFoldersModal.value = false;
|
||||
}
|
||||
|
||||
function openLastSavedItemInFolder() {
|
||||
emit('openAddFolders');
|
||||
}
|
||||
|
||||
function openLastItemAfterDeleteInFolder() {
|
||||
emit('openDeleteFolders');
|
||||
}
|
||||
|
||||
function setParamsForEditFolderModal() {
|
||||
// Here we are setting the params for edit folder modal to show the existing values.
|
||||
|
||||
// For agent, team, inboxes,and campaigns we get only the id's from the query.
|
||||
// So we are mapping the id's to the actual values.
|
||||
|
||||
// For labels we get the name of the label from the query.
|
||||
// If we delete the label from the label list then we will not be able to show the label name.
|
||||
|
||||
// For custom attributes we get only attribute key.
|
||||
// So we are mapping it to find the input type of the attribute to show in the edit folder modal.
|
||||
return {
|
||||
agents: agentList.value,
|
||||
teams: teamsList.value,
|
||||
inboxes: inboxesList.value,
|
||||
labels: labels.value,
|
||||
campaigns: campaigns.value,
|
||||
languages: languages,
|
||||
countries: countries,
|
||||
priority: [
|
||||
{ id: 'low', name: t('CONVERSATION.PRIORITY.OPTIONS.LOW') },
|
||||
{ id: 'medium', name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM') },
|
||||
{ id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
|
||||
{ id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
|
||||
],
|
||||
filterTypes: advancedFilterTypes.value,
|
||||
allCustomAttributes: conversationCustomAttributes.value,
|
||||
};
|
||||
}
|
||||
|
||||
function initializeExistingFilterToModal() {
|
||||
const statusFilter = initializeStatusAndAssigneeFilterToModal(
|
||||
props.activeStatus,
|
||||
props.currentUserDetails,
|
||||
props.activeAssigneeTab
|
||||
);
|
||||
if (statusFilter) {
|
||||
appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)];
|
||||
}
|
||||
|
||||
const otherFilters = initializeInboxTeamAndLabelFilterToModal(
|
||||
props.conversationInbox,
|
||||
inbox.value,
|
||||
props.teamId,
|
||||
activeTeam.value,
|
||||
props.label
|
||||
).map(useCamelCase);
|
||||
|
||||
appliedFilter.value = [...appliedFilter.value, ...otherFilters];
|
||||
}
|
||||
|
||||
function initializeFolderToFilterModal(newActiveFolder) {
|
||||
const query = unref(newActiveFolder)?.query?.payload;
|
||||
if (!Array.isArray(query)) return;
|
||||
|
||||
const newFilters = query.map(filter => {
|
||||
const transformed = useCamelCase(filter);
|
||||
const values = Array.isArray(transformed.values)
|
||||
? generateValuesForEditCustomViews(
|
||||
useSnakeCase(filter),
|
||||
setParamsForEditFolderModal()
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
attributeKey: transformed.attributeKey,
|
||||
attributeModel: transformed.attributeModel,
|
||||
customAttributeType: transformed.customAttributeType,
|
||||
filterOperator: transformed.filterOperator,
|
||||
queryOperator: transformed.queryOperator ?? 'and',
|
||||
values,
|
||||
};
|
||||
});
|
||||
|
||||
appliedFilter.value = [...appliedFilter.value, ...newFilters];
|
||||
}
|
||||
|
||||
function initalizeAppliedFiltersToModal() {
|
||||
appliedFilter.value = [...appliedFilters.value];
|
||||
}
|
||||
|
||||
function onToggleAdvanceFiltersModal() {
|
||||
if (showAdvancedFilters.value === true) {
|
||||
closeAdvanceFiltersModal();
|
||||
return;
|
||||
}
|
||||
|
||||
appliedFilter.value = [];
|
||||
|
||||
if (!props.hasAppliedFilters && !props.hasActiveFolders) {
|
||||
initializeExistingFilterToModal();
|
||||
}
|
||||
if (props.hasActiveFolders) {
|
||||
initializeFolderToFilterModal(activeFolder.value);
|
||||
}
|
||||
if (props.hasAppliedFilters) {
|
||||
initalizeAppliedFiltersToModal();
|
||||
}
|
||||
|
||||
showAdvancedFilters.value = true;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
onToggleAdvanceFiltersModal,
|
||||
onClickOpenAddFoldersModal,
|
||||
onClickOpenDeleteFoldersModal,
|
||||
onCloseAddFoldersModal,
|
||||
onCloseDeleteFoldersModal,
|
||||
foldersQuery,
|
||||
activeFolder,
|
||||
appliedFilter,
|
||||
showAdvancedFilters,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<TeleportWithDirection
|
||||
v-if="showAddFoldersModal"
|
||||
to="#saveFilterTeleportTarget"
|
||||
>
|
||||
<SaveCustomView
|
||||
v-model="appliedFilter"
|
||||
:custom-views-query="foldersQuery"
|
||||
:open-last-saved-item="openLastSavedItemInFolder"
|
||||
@close="onCloseAddFoldersModal"
|
||||
/>
|
||||
</TeleportWithDirection>
|
||||
|
||||
<TeleportWithDirection
|
||||
v-if="showDeleteFoldersModal"
|
||||
to="#deleteFilterTeleportTarget"
|
||||
>
|
||||
<DeleteCustomViews
|
||||
v-model:show="showDeleteFoldersModal"
|
||||
:active-custom-view="activeFolder"
|
||||
:custom-views-id="foldersId"
|
||||
:open-last-item-after-delete="openLastItemAfterDeleteInFolder"
|
||||
@close="onCloseDeleteFoldersModal"
|
||||
/>
|
||||
</TeleportWithDirection>
|
||||
|
||||
<TeleportWithDirection
|
||||
v-if="showAdvancedFilters"
|
||||
to="#conversationFilterTeleportTarget"
|
||||
>
|
||||
<ConversationFilter
|
||||
v-model="appliedFilter"
|
||||
:folder-name="activeFolderName"
|
||||
:is-folder-view="hasActiveFolders"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-folder="onUpdateSavedFilter"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
/>
|
||||
</TeleportWithDirection>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,15 +16,10 @@ const props = defineProps({
|
||||
isOnExpandedLayout: { type: Boolean, required: true },
|
||||
conversationStats: { type: Object, required: true },
|
||||
isListLoading: { type: Boolean, required: true },
|
||||
isFilterModalOpen: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'addFolders',
|
||||
'deleteFolders',
|
||||
'resetFilters',
|
||||
'basicFilterChange',
|
||||
'filtersModal',
|
||||
]);
|
||||
const emit = defineEmits(['basicFilterChange', 'filtersModal']);
|
||||
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
@@ -56,61 +51,33 @@ const toggleConversationLayout = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 h-[3.25rem]"
|
||||
:class="{
|
||||
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 px-4 h-[3.25rem]">
|
||||
<div class="flex items-center justify-center min-w-0">
|
||||
<h1
|
||||
class="text-base font-medium truncate text-n-slate-12"
|
||||
:title="pageTitle"
|
||||
>
|
||||
<h1 class="text-heading-2 truncate text-n-slate-12" :title="pageTitle">
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="
|
||||
allCount > 0 && hasAppliedFiltersOrActiveFolders && !isListLoading
|
||||
"
|
||||
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
|
||||
class="px-2 py-1 my-0.5 mx-1 rounded-lg capitalize -outline-offset-1 outline outline-1 outline-n-container bg-n-button-color text-xxs text-n-slate-12 shrink-0"
|
||||
:title="allCount"
|
||||
>
|
||||
{{ formattedAllCount }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
|
||||
class="px-2 py-1 my-0.5 mx-1 rounded-lg capitalize -outline-offset-1 outline outline-1 outline-n-container bg-n-button-color text-xxs text-n-slate-12 shrink-0"
|
||||
>
|
||||
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-if="hasAppliedFilters && !hasActiveFolders">
|
||||
<div class="relative">
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
|
||||
icon="i-lucide-save"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="emit('addFolders')"
|
||||
/>
|
||||
<div
|
||||
id="saveFilterTeleportTarget"
|
||||
class="absolute z-50 mt-2"
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
|
||||
icon="i-lucide-circle-x"
|
||||
ruby
|
||||
faded
|
||||
xs
|
||||
@click="emit('resetFilters')"
|
||||
/>
|
||||
</template>
|
||||
<div class="flex items-center gap-2">
|
||||
<ConversationBasicFilter
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@change-filter="onBasicFilterChange"
|
||||
/>
|
||||
<template v-if="hasActiveFolders">
|
||||
<div class="relative">
|
||||
<NextButton
|
||||
@@ -118,8 +85,8 @@ const toggleConversationLayout = () => {
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
|
||||
icon="i-lucide-pen-line"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
sm
|
||||
:variant="isFilterModalOpen ? 'faded' : 'ghost'"
|
||||
@click="emit('filtersModal')"
|
||||
/>
|
||||
<div
|
||||
@@ -128,15 +95,6 @@ const toggleConversationLayout = () => {
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
id="toggleConversationFilterButton"
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
|
||||
icon="i-lucide-trash-2"
|
||||
ruby
|
||||
xs
|
||||
faded
|
||||
@click="emit('deleteFolders')"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="relative">
|
||||
<NextButton
|
||||
@@ -144,8 +102,8 @@ const toggleConversationLayout = () => {
|
||||
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
||||
icon="i-lucide-list-filter"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
sm
|
||||
:variant="isFilterModalOpen ? 'faded' : 'ghost'"
|
||||
@click="emit('filtersModal')"
|
||||
/>
|
||||
<div
|
||||
@@ -154,11 +112,6 @@ const toggleConversationLayout = () => {
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
<ConversationBasicFilter
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@change-filter="onBasicFilterChange"
|
||||
/>
|
||||
<SwitchLayout
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@toggle="toggleConversationLayout"
|
||||
|
||||
@@ -1,51 +1,170 @@
|
||||
<script>
|
||||
import ConversationCard from './widgets/conversation/ConversationCard.vue';
|
||||
export default {
|
||||
components: {
|
||||
ConversationCard,
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardV5.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
source: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
inject: [
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'assignAgent',
|
||||
'assignTeam',
|
||||
'assignLabels',
|
||||
'removeLabels',
|
||||
'updateConversationStatus',
|
||||
'toggleContextMenu',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'isConversationSelected',
|
||||
'deleteConversation',
|
||||
],
|
||||
props: {
|
||||
source: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
teamId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
foldersId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
showAssignee: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
teamId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
};
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
foldersId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
showAssignee: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isExpandedLayout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'deleteConversation',
|
||||
'showResolveAttributesModal',
|
||||
'redirectToList',
|
||||
]);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectConversation = inject('selectConversation');
|
||||
const deSelectConversation = inject('deSelectConversation');
|
||||
const toggleContextMenu = inject('toggleContextMenu');
|
||||
const isConversationSelected = inject('isConversationSelected');
|
||||
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const { onAssignAgent, onAssignLabels, onRemoveLabels } = useBulkActions();
|
||||
|
||||
async function assignTeam(team, conversationId = null) {
|
||||
try {
|
||||
await store.dispatch('assignTeam', {
|
||||
conversationId,
|
||||
teamId: team.id,
|
||||
});
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.SUCCESFUL', {
|
||||
team: team.name,
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.FAILED'));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleConversationStatus(
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
customAttributes = null
|
||||
) {
|
||||
const payload = {
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
};
|
||||
|
||||
if (customAttributes) {
|
||||
payload.customAttributes = customAttributes;
|
||||
}
|
||||
|
||||
store.dispatch('toggleStatus', payload).then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
});
|
||||
}
|
||||
|
||||
function updateConversationStatus(conversationId, status, snoozedUntil) {
|
||||
if (status !== wootConstants.STATUS_TYPE.RESOLVED) {
|
||||
toggleConversationStatus(conversationId, status, snoozedUntil);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for required attributes before resolving
|
||||
const conversation = store.getters.getConversationById(conversationId);
|
||||
const currentCustomAttributes = conversation?.custom_attributes || {};
|
||||
const { hasMissing, missing } = checkMissingAttributes(
|
||||
currentCustomAttributes
|
||||
);
|
||||
|
||||
if (hasMissing) {
|
||||
// Emit event to parent to show modal
|
||||
emit('showResolveAttributesModal', {
|
||||
conversationId,
|
||||
snoozedUntil,
|
||||
missing,
|
||||
currentCustomAttributes,
|
||||
});
|
||||
} else {
|
||||
toggleConversationStatus(conversationId, status, snoozedUntil);
|
||||
}
|
||||
}
|
||||
|
||||
async function assignPriority(priority, conversationId = null) {
|
||||
store.dispatch('setCurrentChatPriority', {
|
||||
priority,
|
||||
conversationId,
|
||||
});
|
||||
store.dispatch('assignPriority', { conversationId, priority }).then(() => {
|
||||
useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
|
||||
newValue: priority,
|
||||
from: 'Context menu',
|
||||
});
|
||||
useAlert(
|
||||
t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
|
||||
priority,
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function markAsUnread(conversationId) {
|
||||
try {
|
||||
await store.dispatch('markMessagesUnread', {
|
||||
id: conversationId,
|
||||
});
|
||||
emit('redirectToList');
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(conversationId) {
|
||||
try {
|
||||
await store.dispatch('markMessagesRead', {
|
||||
id: conversationId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
emit('deleteConversation', props.source.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,18 +177,19 @@ export default {
|
||||
:conversation-type="conversationType"
|
||||
:selected="isConversationSelected(source.id)"
|
||||
:show-assignee="showAssignee"
|
||||
:is-expanded-layout="isExpandedLayout"
|
||||
enable-context-menu
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
@assign-agent="assignAgent"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-team="assignTeam"
|
||||
@assign-label="assignLabels"
|
||||
@remove-label="removeLabels"
|
||||
@assign-label="onAssignLabels"
|
||||
@remove-label="onRemoveLabels"
|
||||
@update-conversation-status="updateConversationStatus"
|
||||
@context-menu-toggle="toggleContextMenu"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
@delete-conversation="handleDelete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<script setup>
|
||||
import { provide, useTemplateRef, shallowRef, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import { useInfiniteScroll, useBreakpoints } from '@vueuse/core';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
isOnMentionsView,
|
||||
isOnUnattendedView,
|
||||
} from 'dashboard/store/modules/conversations/helpers/actionHelpers';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
showEndOfListMessage: { type: Boolean, default: false },
|
||||
isContextMenuOpen: { type: Boolean, default: false },
|
||||
label: { type: String, default: '' },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
conversationType: { type: String, default: '' },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
isExpandedLayout: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['loadMore']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const parentRef = useTemplateRef('parentRef');
|
||||
const virtualizerRef = shallowRef(null);
|
||||
const deleteConversationDialogRef = ref(null);
|
||||
const conversationToDelete = ref(null);
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
|
||||
provide('contextMenuElementTarget', parentRef);
|
||||
|
||||
const breakpoints = useBreakpoints({
|
||||
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
|
||||
});
|
||||
const isLargeScreen = breakpoints.greaterOrEqual('lg');
|
||||
provide('isLargeScreen', isLargeScreen);
|
||||
|
||||
useInfiniteScroll(
|
||||
parentRef,
|
||||
() => {
|
||||
if (props.isLoading || props.showEndOfListMessage) return;
|
||||
emit('loadMore');
|
||||
},
|
||||
{
|
||||
distance: 400,
|
||||
}
|
||||
);
|
||||
|
||||
// Keyboard navigation for conversation list
|
||||
const handleConversationNavigation = direction => {
|
||||
if (!parentRef.value) return;
|
||||
|
||||
// Get all wrapper divs with data-id
|
||||
const allWrappers = Array.from(parentRef.value.querySelectorAll('[data-id]'));
|
||||
if (allWrappers.length === 0) return;
|
||||
|
||||
const activeIndex = allWrappers.findIndex(wrapper =>
|
||||
wrapper.querySelector('.active')
|
||||
);
|
||||
|
||||
const delta = direction === 'previous' ? -1 : 1;
|
||||
const newIndex = activeIndex + delta;
|
||||
// Clamp index to valid range [0, length-1]
|
||||
const targetIndex = Math.max(0, Math.min(newIndex, allWrappers.length - 1));
|
||||
|
||||
// Click the first child (ConversationItem root element)
|
||||
allWrappers[targetIndex]?.firstElementChild?.click();
|
||||
};
|
||||
|
||||
useKeyboardEvents({
|
||||
'Alt+KeyJ': {
|
||||
action: () => handleConversationNavigation('previous'),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyK': {
|
||||
action: () => handleConversationNavigation('next'),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
function handleDeleteConversation(conversationId) {
|
||||
conversationToDelete.value = conversationId;
|
||||
deleteConversationDialogRef.value?.open();
|
||||
}
|
||||
|
||||
function redirectToConversationList() {
|
||||
const {
|
||||
params: { accountId, inbox_id: inboxId, label, teamId },
|
||||
name,
|
||||
} = route;
|
||||
|
||||
let conversationType = '';
|
||||
if (isOnMentionsView({ route: { name } })) {
|
||||
conversationType = 'mention';
|
||||
} else if (isOnUnattendedView({ route: { name } })) {
|
||||
conversationType = 'unattended';
|
||||
}
|
||||
router.push(
|
||||
conversationListPageURL({
|
||||
accountId,
|
||||
conversationType: conversationType,
|
||||
customViewId: props.foldersId,
|
||||
inboxId,
|
||||
label,
|
||||
teamId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteConversation() {
|
||||
if (!conversationToDelete.value) return;
|
||||
|
||||
try {
|
||||
await store.dispatch('deleteConversation', conversationToDelete.value);
|
||||
redirectToConversationList();
|
||||
deleteConversationDialogRef.value?.close();
|
||||
conversationToDelete.value = null;
|
||||
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleShowResolveAttributesModal({
|
||||
conversationId,
|
||||
snoozedUntil,
|
||||
missing,
|
||||
currentCustomAttributes,
|
||||
}) {
|
||||
const conversationContext = {
|
||||
id: conversationId,
|
||||
snoozedUntil,
|
||||
};
|
||||
resolveAttributesModalRef.value?.open(
|
||||
missing,
|
||||
currentCustomAttributes,
|
||||
conversationContext
|
||||
);
|
||||
}
|
||||
|
||||
function toggleConversationStatus(
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
customAttributes = null
|
||||
) {
|
||||
const payload = {
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
};
|
||||
|
||||
if (customAttributes) {
|
||||
payload.customAttributes = customAttributes;
|
||||
}
|
||||
|
||||
store.dispatch('toggleStatus', payload).then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
});
|
||||
}
|
||||
|
||||
function handleResolveWithAttributes({ attributes, context }) {
|
||||
if (context) {
|
||||
const existingConversation = store.getters.getConversationById(context.id);
|
||||
const currentCustomAttributes =
|
||||
existingConversation?.custom_attributes || {};
|
||||
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
|
||||
|
||||
toggleConversationStatus(
|
||||
context.id,
|
||||
wootConstants.STATUS_TYPE.RESOLVED,
|
||||
context.snoozedUntil,
|
||||
mergedAttributes
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="parentRef"
|
||||
class="flex-1 w-full h-full touch-pan-y overflow-x-hidden overscroll-contain [-webkit-overflow-scrolling:touch] [contain:strict] [overflow-anchor:none] [will-change:scroll-position] px-2 pt-2"
|
||||
:class="isContextMenuOpen ? 'overflow-hidden' : 'overflow-y-auto'"
|
||||
>
|
||||
<Virtualizer
|
||||
ref="virtualizerRef"
|
||||
:data="items"
|
||||
:scroll-ref="parentRef"
|
||||
:item-size="isLargeScreen && isExpandedLayout ? 48 : 76"
|
||||
:buffer-size="100"
|
||||
item="div"
|
||||
class="[&>div]:after:content-[''] [&>div]:after:absolute [&>div]:after:bottom-0 [&>div]:after:left-0 [&>div]:after:right-0 [&>div]:after:h-px [&>div]:after:bg-n-weak [&>div]:after:pointer-events-none [&>div:has(*:hover)]:after:!bg-n-surface-1 [&>div:has(+_*:hover)]:after:!bg-n-surface-1 [&>div:has(.active)]:after:!bg-n-surface-1 [&>div:has(+_*_.active)]:after:!bg-n-surface-1 [&>div:has(.selected)]:after:!bg-n-surface-1 [&>div:has(+_*_.selected)]:after:!bg-n-surface-1"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div :key="item.id" :data-id="item.id">
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssignee"
|
||||
:is-expanded-layout="isExpandedLayout"
|
||||
@delete-conversation="handleDeleteConversation"
|
||||
@show-resolve-attributes-modal="handleShowResolveAttributesModal"
|
||||
@redirect-to-list="redirectToConversationList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Virtualizer>
|
||||
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
type="alert"
|
||||
:title="
|
||||
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
|
||||
conversationId: conversationToDelete,
|
||||
})
|
||||
"
|
||||
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
|
||||
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
|
||||
@confirm="deleteConversation"
|
||||
/>
|
||||
|
||||
<ConversationResolveAttributesModal
|
||||
ref="resolveAttributesModalRef"
|
||||
@submit="handleResolveWithAttributes"
|
||||
/>
|
||||
|
||||
<div class="py-2">
|
||||
<div v-if="isLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-sm text-n-slate-10 whitespace-nowrap"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-party"
|
||||
class="size-4 inline-block align-middle ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
<span class="align-middle">
|
||||
{{ t('CHAT_LIST.EOF') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
|
||||
|
||||
const props = defineProps({
|
||||
index: {
|
||||
@@ -28,11 +29,14 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const { formatCompactWithDecimal } = useNumberFormatter();
|
||||
|
||||
const activeIndex = inject('activeIndex');
|
||||
const updateActiveIndex = inject('updateActiveIndex');
|
||||
|
||||
const active = computed(() => props.index === activeIndex.value);
|
||||
const getItemCount = computed(() => props.count);
|
||||
const count = computed(() => props.count);
|
||||
const getItemCount = computed(() => formatCompactWithDecimal(count.value));
|
||||
|
||||
const onTabClick = event => {
|
||||
event.preventDefault();
|
||||
@@ -59,7 +63,11 @@ const onTabClick = event => {
|
||||
{{ name }}
|
||||
<div
|
||||
v-if="showBadge"
|
||||
class="rounded-full h-5 flex items-center justify-center text-xs font-medium my-0 ltr:ml-1 rtl:mr-1 px-1.5 py-0 min-w-[20px]"
|
||||
v-tooltip.top="{
|
||||
content: count,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="rounded-full h-5 flex items-center justify-center font-interDisplay text-xs font-medium my-0 ltr:ml-1 rtl:mr-1 px-1.5 py-0 min-w-[20px]"
|
||||
:class="[
|
||||
active
|
||||
? 'bg-n-blue-3 text-n-blue-11'
|
||||
|
||||
@@ -48,13 +48,13 @@ useKeyboardEvents(keyboardEvents);
|
||||
<template>
|
||||
<woot-tabs
|
||||
:index="activeTabIndex"
|
||||
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0 h-10"
|
||||
class="w-full px-4 py-0 [&_ul]:p-0 h-10"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
v-for="(item, index) in items"
|
||||
:key="item.key"
|
||||
class="text-sm [&_a]:font-medium"
|
||||
class="text-sm"
|
||||
:index="index"
|
||||
:name="item.name"
|
||||
:count="item.count"
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
|
||||
import ActiveFilterPreview from 'dashboard/components-next/filter/ActiveFilterPreview.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeFolder: { type: Object, default: null },
|
||||
hasActiveFolders: { type: Boolean, default: false },
|
||||
isOnExpandedLayout: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'clearFilters',
|
||||
'saveFolder',
|
||||
'deleteFolder',
|
||||
'openFilter',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
|
||||
|
||||
const activeFolderQuery = computed(() => {
|
||||
const query = props.activeFolder?.query?.payload;
|
||||
if (!Array.isArray(query)) return [];
|
||||
|
||||
const newFilters = query.map(filter => {
|
||||
const transformed = useCamelCase(filter);
|
||||
return {
|
||||
attributeKey: transformed.attributeKey,
|
||||
attributeModel: transformed.attributeModel,
|
||||
customAttributeType: transformed.customAttributeType,
|
||||
filterOperator: transformed.filterOperator,
|
||||
queryOperator: transformed.queryOperator ?? 'and',
|
||||
values: transformed.values,
|
||||
};
|
||||
});
|
||||
|
||||
return newFilters;
|
||||
});
|
||||
|
||||
const activeFilterQueryData = computed(() => {
|
||||
return props.hasActiveFolders
|
||||
? activeFolderQuery.value
|
||||
: appliedFilters.value;
|
||||
});
|
||||
|
||||
const showPreview = computed(() => {
|
||||
return activeFilterQueryData.value.length > 0;
|
||||
});
|
||||
|
||||
const containerRef = ref(null);
|
||||
const { width: containerWidth } = useElementSize(containerRef);
|
||||
|
||||
const maxVisibleFilters = computed(() => {
|
||||
const totalFilters = activeFilterQueryData.value.length;
|
||||
const width = containerWidth.value;
|
||||
|
||||
if (!width || totalFilters === 0) return 0;
|
||||
|
||||
const chipWidth = 200; // Estimated avg width + gap
|
||||
const buttonSpace = 400; // Reserved space for "Edit/Delete/More" buttons
|
||||
|
||||
// Calculate available space across 2 lines
|
||||
const totalAvailablePixels = Math.max(0, width * 2 - buttonSpace);
|
||||
const maxCapacity = Math.floor(totalAvailablePixels / chipWidth);
|
||||
// Return at least 1, but no more than actual total
|
||||
return Math.max(1, Math.min(maxCapacity, totalFilters));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showPreview"
|
||||
ref="containerRef"
|
||||
class="flex items-center justify-between gap-2 mx-2 px-2 pb-2 pt-0.5 border-b-[1.2px] border-dashed border-n-strong mt-px"
|
||||
>
|
||||
<ActiveFilterPreview
|
||||
:applied-filters="activeFilterQueryData"
|
||||
:max-visible-filters="maxVisibleFilters"
|
||||
:more-filters-label="
|
||||
t('FILTER.ACTIVE_FILTERS.MORE_FILTERS', {
|
||||
count: activeFilterQueryData.length - maxVisibleFilters,
|
||||
})
|
||||
"
|
||||
:clear-button-label="t('FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')"
|
||||
:show-clear-button="!hasActiveFolders"
|
||||
class="flex-1 min-w-0"
|
||||
@open-filter="emit('openFilter')"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
>
|
||||
<template #actions>
|
||||
<template v-if="hasActiveFolders">
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg" />
|
||||
<Button
|
||||
:label="t('FILTER.ACTIVE_FILTERS.EDIT_BUTTON')"
|
||||
link
|
||||
class="hover:!no-underline !text-n-blue-11 !px-0.5"
|
||||
xs
|
||||
@click="emit('openFilter')"
|
||||
/>
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg" />
|
||||
<div class="inline-flex items-center">
|
||||
<Button
|
||||
:label="t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
|
||||
link
|
||||
ruby
|
||||
class="hover:!no-underline !no-underline !px-0.5"
|
||||
xs
|
||||
@click="emit('deleteFolder')"
|
||||
/>
|
||||
<!-- Teleport target for Delete modal -->
|
||||
<div
|
||||
id="deleteFilterTeleportTarget"
|
||||
class="absolute z-50 mt-2"
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="appliedFilters.length > 0"
|
||||
class="w-px h-3 bg-n-weak rounded-lg"
|
||||
/>
|
||||
<div
|
||||
v-if="appliedFilters.length > 0"
|
||||
class="relative inline-flex items-center"
|
||||
>
|
||||
<Button
|
||||
:label="t('FILTER.CUSTOM_VIEWS.SAVE_VIEW')"
|
||||
link
|
||||
class="hover:!no-underline !text-n-blue-11 !px-0.5"
|
||||
xs
|
||||
@click="emit('saveFolder')"
|
||||
/>
|
||||
<!-- Teleport target for Save modal -->
|
||||
<div
|
||||
id="saveFilterTeleportTarget"
|
||||
class="absolute z-50 top-6"
|
||||
:class="{ 'ltr:left-0 rtl:right-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ActiveFilterPreview>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
+9
-5
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useTemplateRef, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
@@ -26,6 +26,7 @@ const { updateUISettings } = useUISettings();
|
||||
const chatStatusFilter = useMapGetter('getChatStatusFilter');
|
||||
const chatSortFilter = useMapGetter('getChatSortFilter');
|
||||
|
||||
const filterContainerRef = useTemplateRef('filterContainerRef');
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const currentStatusFilter = computed(() => {
|
||||
@@ -131,18 +132,21 @@ const handleSortChange = value => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex">
|
||||
<div ref="filterContainerRef" class="relative flex">
|
||||
<NextButton
|
||||
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
|
||||
icon="i-lucide-arrow-up-down"
|
||||
slate
|
||||
faded
|
||||
xs
|
||||
sm
|
||||
:variant="showActionsDropdown ? 'faded' : 'ghost'"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<div
|
||||
v-if="showActionsDropdown"
|
||||
v-on-click-outside="() => toggleDropdown()"
|
||||
v-on-click-outside="[
|
||||
() => toggleDropdown(),
|
||||
{ ignore: [filterContainerRef] },
|
||||
]"
|
||||
class="mt-1 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 absolute z-40 top-full"
|
||||
:class="{
|
||||
'ltr:left-0 rtl:right-0': !isOnExpandedLayout,
|
||||
|
||||
@@ -100,6 +100,9 @@ export default {
|
||||
v-if="currentChat.id"
|
||||
:chat="currentChat"
|
||||
:show-back-button="isOnExpandedLayout && !isInboxView"
|
||||
:class="{
|
||||
'border-b border-b-n-weak !pt-2': !dashboardApps.length,
|
||||
}"
|
||||
/>
|
||||
<woot-tabs
|
||||
v-if="dashboardApps.length && currentChat.id"
|
||||
|
||||
@@ -243,7 +243,7 @@ const deleteConversation = () => {
|
||||
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
|
||||
'bg-n-slate-2': selected,
|
||||
'px-0': compact,
|
||||
'px-3': !compact,
|
||||
'px-2 rounded-lg': !compact,
|
||||
}"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
|
||||
-254
@@ -1,254 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Avatar,
|
||||
Spinner,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
emits: ['select', 'close'],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedAgent: null,
|
||||
goBackToAgentList: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'bulkActions/getUIFlags',
|
||||
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
}),
|
||||
filteredAgents() {
|
||||
if (this.query) {
|
||||
return this.assignableAgents.filter(agent =>
|
||||
agent.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
);
|
||||
}
|
||||
return [
|
||||
{
|
||||
confirmed: true,
|
||||
name: 'None',
|
||||
id: null,
|
||||
role: 'agent',
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
},
|
||||
...this.assignableAgents,
|
||||
];
|
||||
},
|
||||
assignableAgents() {
|
||||
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
|
||||
this.selectedInboxes.join(',')
|
||||
);
|
||||
},
|
||||
conversationLabel() {
|
||||
return this.conversationCount > 1 ? 'conversations' : 'conversation';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', this.selectedInboxes);
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.$emit('select', this.selectedAgent);
|
||||
},
|
||||
goBack() {
|
||||
this.goBackToAgentList = true;
|
||||
this.selectedAgent = null;
|
||||
},
|
||||
assignAgent(agent) {
|
||||
this.selectedAgent = agent;
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
onCloseAgentList() {
|
||||
if (this.selectedAgent === null && !this.goBackToAgentList) {
|
||||
this.onClose();
|
||||
}
|
||||
this.goBackToAgentList = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between header">
|
||||
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="container">
|
||||
<div
|
||||
v-if="assignableAgentsUiFlags.isFetching"
|
||||
class="agent__list-loading"
|
||||
>
|
||||
<Spinner />
|
||||
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
|
||||
</div>
|
||||
<div v-else class="agent__list-container">
|
||||
<ul v-if="!selectedAgent">
|
||||
<li class="search-container">
|
||||
<div
|
||||
class="flex items-center justify-between h-8 gap-2 agent-list-search"
|
||||
>
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="reset-base !outline-0 !text-sm agent--search_input"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li v-for="agent in filteredAgents" :key="agent.id">
|
||||
<div class="agent-list-item" @click="assignAgent(agent)">
|
||||
<Avatar
|
||||
:name="agent.name"
|
||||
:src="agent.thumbnail"
|
||||
:status="agent.availability_status"
|
||||
:size="22"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<span class="my-0 text-n-slate-12">
|
||||
{{ agent.name }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="agent-confirmation-container">
|
||||
<p v-if="selectedAgent.id">
|
||||
{{
|
||||
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
|
||||
conversationCount,
|
||||
conversationLabel,
|
||||
})
|
||||
}}
|
||||
<strong>
|
||||
{{ selectedAgent.name }}
|
||||
</strong>
|
||||
<span>?</span>
|
||||
</p>
|
||||
<p v-else>
|
||||
{{
|
||||
$t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
|
||||
conversationCount,
|
||||
conversationLabel,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="agent-confirmation-actions">
|
||||
<NextButton
|
||||
faded
|
||||
sm
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('BULK_ACTION.GO_BACK_LABEL')"
|
||||
@click="goBack"
|
||||
/>
|
||||
<NextButton
|
||||
sm
|
||||
type="submit"
|
||||
:label="$t('BULK_ACTION.YES')"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__agents {
|
||||
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
|
||||
.header {
|
||||
@apply p-2.5;
|
||||
|
||||
span {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply overflow-y-auto max-h-[15rem];
|
||||
.agent__list-container {
|
||||
@apply h-full;
|
||||
}
|
||||
.agent-list-search {
|
||||
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
|
||||
.search-icon {
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
|
||||
.agent--search_input {
|
||||
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
|
||||
}
|
||||
}
|
||||
}
|
||||
.triangle {
|
||||
@apply block z-10 absolute -top-3 text-left ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
}
|
||||
}
|
||||
ul {
|
||||
@apply m-0 list-none;
|
||||
|
||||
li {
|
||||
&:last-child {
|
||||
.agent-list-item {
|
||||
@apply last:rounded-b-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agent-list-item {
|
||||
@apply flex items-center p-2.5 gap-2 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
|
||||
span {
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-confirmation-container {
|
||||
@apply flex flex-col h-full p-2.5;
|
||||
p {
|
||||
@apply flex-grow;
|
||||
}
|
||||
.agent-confirmation-actions {
|
||||
@apply w-full grid grid-cols-2 gap-2.5;
|
||||
}
|
||||
}
|
||||
.search-container {
|
||||
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
|
||||
}
|
||||
|
||||
.agent__list-loading {
|
||||
@apply m-2.5 rounded-md dark:bg-n-solid-3 bg-n-slate-2 flex items-center justify-center flex-col p-5 h-[calc(95%-6.25rem)];
|
||||
}
|
||||
</style>
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, computed, ref } from 'vue';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedAgent = ref(null);
|
||||
|
||||
const assignableAgentsUiFlags = useMapGetter(
|
||||
'inboxAssignableAgents/getUIFlags'
|
||||
);
|
||||
|
||||
const isLoading = computed(() => assignableAgentsUiFlags.value.isFetching);
|
||||
|
||||
const assignableAgentsList = useMapGetter(
|
||||
'inboxAssignableAgents/getAssignableAgents'
|
||||
);
|
||||
const assignableAgents = computed(() =>
|
||||
assignableAgentsList.value(props.selectedInboxes.join(','))
|
||||
);
|
||||
|
||||
const agentMenuItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
action: 'select',
|
||||
value: 'none',
|
||||
label: 'None',
|
||||
thumbnail: {
|
||||
name: 'None',
|
||||
src: '',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
assignableAgents.value.forEach(agent => {
|
||||
items.push({
|
||||
action: 'select',
|
||||
value: agent.id,
|
||||
label: agent.name,
|
||||
thumbnail: {
|
||||
name: agent.name,
|
||||
src: agent.thumbnail,
|
||||
},
|
||||
isSelected: selectedAgent.value?.id === agent.id,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleSelectAgent = item => {
|
||||
if (item.value === 'none') {
|
||||
selectedAgent.value = { id: null, name: 'None' };
|
||||
} else {
|
||||
const agent = assignableAgents.value.find(a => a.id === item.value);
|
||||
selectedAgent.value = agent || { id: null, name: 'None' };
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
emit('select', selectedAgent.value);
|
||||
selectedAgent.value = null;
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
selectedAgent.value = null;
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
selectedAgent.value = null;
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
const handleToggleDropdown = () => {
|
||||
const willOpen = !showDropdown.value;
|
||||
toggleDropdown();
|
||||
|
||||
// Fetch agents only when opening the dropdown
|
||||
if (willOpen && props.selectedInboxes.length > 0) {
|
||||
store.dispatch('inboxAssignableAgents/fetch', props.selectedInboxes);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<Button
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
|
||||
icon="i-lucide-user-round-check"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
:class="{ 'bg-n-alpha-2': showDropdown }"
|
||||
@click="handleToggleDropdown"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
|
||||
:menu-items="agentMenuItems"
|
||||
:is-loading="isLoading"
|
||||
show-search
|
||||
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="ltr:-right-10 rtl:-left-10 ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-60 max-h-80 overflow-y-auto"
|
||||
@action="handleSelectAgent"
|
||||
>
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="selectedAgent"
|
||||
class="pt-2 pb-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px] after:absolute after:-bottom-2 after:left-0 after:w-full after:h-2 after:bg-n-alpha-3 after:backdrop-blur-[4px]"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<I18nT
|
||||
v-if="selectedAgent.id"
|
||||
keypath="BULK_ACTION.ASSIGN_CONFIRMATION_LABEL"
|
||||
tag="p"
|
||||
class="text-xs text-n-slate-11 px-1 mb-0"
|
||||
:plural="props.conversationCount"
|
||||
>
|
||||
<template #conversationCount>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ props.conversationCount }}
|
||||
</strong>
|
||||
</template>
|
||||
<template #agentName>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ selectedAgent.name }}
|
||||
</strong>
|
||||
</template>
|
||||
</I18nT>
|
||||
<I18nT
|
||||
v-else
|
||||
keypath="BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL"
|
||||
tag="p"
|
||||
class="text-xs text-n-slate-11 px-1 mb-0"
|
||||
:plural="props.conversationCount"
|
||||
>
|
||||
<template #n>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ props.conversationCount }}
|
||||
</strong>
|
||||
</template>
|
||||
</I18nT>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
class="flex-1"
|
||||
:label="t('BULK_ACTION.CANCEL')"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
class="flex-1"
|
||||
:label="t('BULK_ACTION.YES')"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'conversation',
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['assign']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedLabels = ref([]);
|
||||
|
||||
const isTypeContact = computed(() => props.type === 'contact');
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (props.type === 'contact') return t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
|
||||
return '';
|
||||
});
|
||||
|
||||
const isLabelSelected = labelTitle => {
|
||||
return selectedLabels.value.includes(labelTitle);
|
||||
};
|
||||
|
||||
const labelMenuItems = computed(() => {
|
||||
return labels.value.map(label => ({
|
||||
action: 'select',
|
||||
value: label.title,
|
||||
label: label.title,
|
||||
color: label.color,
|
||||
id: label.id,
|
||||
isSelected: isLabelSelected(label.title),
|
||||
}));
|
||||
});
|
||||
|
||||
const toggleLabelSelection = labelTitle => {
|
||||
const index = selectedLabels.value.indexOf(labelTitle);
|
||||
if (index > -1) {
|
||||
selectedLabels.value.splice(index, 1);
|
||||
} else {
|
||||
selectedLabels.value.push(labelTitle);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedLabels.value.length > 0) {
|
||||
emit('assign', selectedLabels.value);
|
||||
toggleDropdown(false);
|
||||
selectedLabels.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
selectedLabels.value = [];
|
||||
toggleDropdown(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<NextButton
|
||||
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-tag"
|
||||
slate
|
||||
:size="isTypeContact ? 'sm' : 'xs'"
|
||||
ghost
|
||||
:class="{
|
||||
'bg-n-alpha-2': showDropdown,
|
||||
'[&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline w-fit':
|
||||
isTypeContact,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:is-loading="isLoading"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
|
||||
:menu-items="labelMenuItems"
|
||||
show-search
|
||||
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="bottom-8 w-60 max-h-80 overflow-y-auto"
|
||||
:class="{
|
||||
'ltr:-right-[6.5rem] rtl:-left-[6.5rem] ltr:2xl:right-0 rtl:2xl:left-0':
|
||||
!isTypeContact,
|
||||
'ltr:right-0 rtl:left-0 mb-1': isTypeContact,
|
||||
}"
|
||||
@action="item => toggleLabelSelection(item.value)"
|
||||
>
|
||||
<template #thumbnail="{ item }">
|
||||
<span
|
||||
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #trailing-icon="{ item }">
|
||||
<Icon
|
||||
v-if="isLabelSelected(item.value)"
|
||||
icon="i-lucide-check"
|
||||
class="size-4 text-n-blue-11 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div
|
||||
class="sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px] after:absolute after:-bottom-2 after:left-0 after:w-full after:h-2 after:bg-n-alpha-3 after:backdrop-blur-[4px]"
|
||||
>
|
||||
<NextButton
|
||||
sm
|
||||
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
|
||||
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, computed, ref, onMounted } from 'vue';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedTeam = ref(null);
|
||||
|
||||
const teams = useMapGetter('teams/getTeams');
|
||||
|
||||
const teamMenuItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
action: 'select',
|
||||
value: 'none',
|
||||
label: t('BULK_ACTION.TEAMS.NONE'),
|
||||
},
|
||||
];
|
||||
|
||||
teams.value.forEach(team => {
|
||||
items.push({
|
||||
action: 'select',
|
||||
value: team.id,
|
||||
label: team.name,
|
||||
isSelected: selectedTeam.value?.id === team.id,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleSelectTeam = item => {
|
||||
if (item.value === 'none') {
|
||||
selectedTeam.value = { id: 0, name: 'None' };
|
||||
} else {
|
||||
const foundTeam = teams.value.find(team => team.id === item.value);
|
||||
selectedTeam.value = foundTeam || { id: 0, name: 'None' };
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
emit('select', selectedTeam.value);
|
||||
selectedTeam.value = null;
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
selectedTeam.value = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('teams/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<Button
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
|
||||
icon="i-lucide-users-round"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
:class="{ 'bg-n-alpha-2': showDropdown }"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-click-outside="[
|
||||
() => toggleDropdown(false),
|
||||
{ ignore: [containerRef] },
|
||||
]"
|
||||
:menu-items="teamMenuItems"
|
||||
show-search
|
||||
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="ltr:-right-2 rtl:-left-2 bottom-8 w-60 max-h-80 overflow-y-auto"
|
||||
@action="handleSelectTeam"
|
||||
>
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="selectedTeam"
|
||||
class="pt-2 pb-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px] after:absolute after:-bottom-2 after:left-0 after:w-full after:h-2 after:bg-n-alpha-3 after:backdrop-blur-[4px]"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<I18nT
|
||||
v-if="selectedTeam.id"
|
||||
keypath="BULK_ACTION.TEAMS.ASSIGN_CONFIRMATION_LABEL"
|
||||
tag="p"
|
||||
class="text-xs text-n-slate-11 px-1 mb-0"
|
||||
:plural="props.conversationCount"
|
||||
>
|
||||
<template #conversationCount>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ props.conversationCount }}
|
||||
</strong>
|
||||
</template>
|
||||
<template #teamName>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ selectedTeam.name }}
|
||||
</strong>
|
||||
</template>
|
||||
</I18nT>
|
||||
<I18nT
|
||||
v-else
|
||||
keypath="BULK_ACTION.TEAMS.UNASSIGN_CONFIRMATION_LABEL"
|
||||
tag="p"
|
||||
class="text-xs text-n-slate-11 px-1 mb-0"
|
||||
:plural="props.conversationCount"
|
||||
>
|
||||
<template #n>
|
||||
<strong class="text-n-slate-12">
|
||||
{{ props.conversationCount }}
|
||||
</strong>
|
||||
</template>
|
||||
</I18nT>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
class="flex-1"
|
||||
:label="t('BULK_ACTION.CANCEL')"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
class="flex-1"
|
||||
:label="t('BULK_ACTION.YES')"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
showResolve: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showReopen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSnooze: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
|
||||
const updateMenuItems = computed(() => {
|
||||
const items = [];
|
||||
|
||||
if (props.showResolve) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
value: 'resolved',
|
||||
label: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
|
||||
icon: 'i-lucide-check',
|
||||
});
|
||||
}
|
||||
|
||||
if (props.showReopen) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
value: 'open',
|
||||
label: t('CONVERSATION.HEADER.REOPEN_ACTION'),
|
||||
icon: 'i-lucide-redo',
|
||||
});
|
||||
}
|
||||
|
||||
if (props.showSnooze) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
value: 'snoozed',
|
||||
label: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
|
||||
icon: 'i-lucide-alarm-clock',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleUpdate = item => {
|
||||
if (item.value === 'snoozed') {
|
||||
// If the user clicks on the snooze option from the bulk action change status dropdown.
|
||||
// Open the snooze option for bulk action in the cmd bar.
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
|
||||
} else {
|
||||
emit('update', item.value);
|
||||
}
|
||||
toggleDropdown(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<Button
|
||||
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
|
||||
icon="i-lucide-circle-fading-arrow-up"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
:class="{ 'bg-n-alpha-2': showDropdown }"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-click-outside="[
|
||||
() => toggleDropdown(false),
|
||||
{ ignore: [containerRef] },
|
||||
]"
|
||||
:menu-items="updateMenuItems"
|
||||
class="ltr:-right-[4.5rem] rtl:-left-[4.5rem] ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-36"
|
||||
@action="handleUpdate"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+160
-299
@@ -1,7 +1,9 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions.js';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
@@ -10,313 +12,172 @@ import {
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import AgentSelector from './AgentSelector.vue';
|
||||
import UpdateActions from './UpdateActions.vue';
|
||||
import LabelActions from './LabelActions.vue';
|
||||
import TeamActions from './TeamActions.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import BulkAgentActions from './BulkAgentActions.vue';
|
||||
import BulkUpdateActions from './BulkUpdateActions.vue';
|
||||
import BulkLabelActions from './BulkLabelActions.vue';
|
||||
import BulkTeamActions from './BulkTeamActions.vue';
|
||||
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
|
||||
export default {
|
||||
components: {
|
||||
AgentSelector,
|
||||
UpdateActions,
|
||||
LabelActions,
|
||||
TeamActions,
|
||||
CustomSnoozeModal,
|
||||
NextButton,
|
||||
|
||||
const props = defineProps({
|
||||
conversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
props: {
|
||||
conversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
allConversationsSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showOpenAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showResolvedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSnoozedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allConversationsSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emits: [
|
||||
'selectAllConversations',
|
||||
'assignAgent',
|
||||
'updateConversations',
|
||||
'assignLabels',
|
||||
'assignTeam',
|
||||
'resolveConversations',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
showAgentsList: false,
|
||||
showUpdateActions: false,
|
||||
showLabelActions: false,
|
||||
showTeamsList: false,
|
||||
popoverPositions: {},
|
||||
showCustomTimeSnoozeModal: false,
|
||||
};
|
||||
selectedInboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
mounted() {
|
||||
emitter.on(
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
this.onCmdSnoozeConversation
|
||||
);
|
||||
emitter.on(
|
||||
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||
this.onCmdReopenConversation
|
||||
);
|
||||
emitter.on(
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
this.onCmdResolveConversation
|
||||
);
|
||||
showOpenAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
unmounted() {
|
||||
emitter.off(
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
this.onCmdSnoozeConversation
|
||||
);
|
||||
emitter.off(
|
||||
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||
this.onCmdReopenConversation
|
||||
);
|
||||
emitter.off(
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
this.onCmdResolveConversation
|
||||
);
|
||||
showResolvedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
methods: {
|
||||
onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomTimeSnoozeModal = true;
|
||||
} else {
|
||||
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
|
||||
}
|
||||
},
|
||||
onCmdReopenConversation() {
|
||||
this.updateConversations('open', null);
|
||||
},
|
||||
onCmdResolveConversation() {
|
||||
this.updateConversations('resolved', null);
|
||||
},
|
||||
customSnoozeTime(customSnoozedTime) {
|
||||
this.showCustomTimeSnoozeModal = false;
|
||||
if (customSnoozedTime) {
|
||||
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
|
||||
}
|
||||
},
|
||||
hideCustomSnoozeModal() {
|
||||
this.showCustomTimeSnoozeModal = false;
|
||||
},
|
||||
selectAll(e) {
|
||||
this.$emit('selectAllConversations', e.target.checked);
|
||||
},
|
||||
submit(agent) {
|
||||
this.$emit('assignAgent', agent);
|
||||
},
|
||||
updateConversations(status, snoozedUntil) {
|
||||
this.$emit('updateConversations', status, snoozedUntil);
|
||||
},
|
||||
assignLabels(labels) {
|
||||
this.$emit('assignLabels', labels);
|
||||
},
|
||||
assignTeam(team) {
|
||||
this.$emit('assignTeam', team);
|
||||
},
|
||||
resolveConversations() {
|
||||
this.$emit('resolveConversations');
|
||||
},
|
||||
toggleUpdateActions() {
|
||||
this.showUpdateActions = !this.showUpdateActions;
|
||||
},
|
||||
toggleLabelActions() {
|
||||
this.showLabelActions = !this.showLabelActions;
|
||||
},
|
||||
toggleAgentList() {
|
||||
this.showAgentsList = !this.showAgentsList;
|
||||
},
|
||||
toggleTeamsList() {
|
||||
this.showTeamsList = !this.showTeamsList;
|
||||
},
|
||||
showSnoozedAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectAllConversations']);
|
||||
|
||||
const {
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onAssignTeamsForBulk: onAssignTeam,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
|
||||
const showCustomTimeSnoozeModal = ref(false);
|
||||
|
||||
function onCmdSnoozeConversation(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
showCustomTimeSnoozeModal.value = true;
|
||||
} else {
|
||||
onUpdateConversations('snoozed', findSnoozeTime(snoozeType) || null);
|
||||
}
|
||||
}
|
||||
|
||||
function onCmdReopenConversation() {
|
||||
onUpdateConversations('open', null);
|
||||
}
|
||||
|
||||
function onCmdResolveConversation() {
|
||||
onUpdateConversations('resolved', null);
|
||||
}
|
||||
|
||||
function customSnoozeTime(customSnoozedTime) {
|
||||
showCustomTimeSnoozeModal.value = false;
|
||||
if (customSnoozedTime) {
|
||||
onUpdateConversations('snoozed', getUnixTime(customSnoozedTime));
|
||||
}
|
||||
}
|
||||
|
||||
function hideCustomSnoozeModal() {
|
||||
showCustomTimeSnoozeModal.value = false;
|
||||
}
|
||||
|
||||
// Computed property with getter/setter to enable v-model usage
|
||||
const allSelected = computed({
|
||||
get: () => props.allConversationsSelected,
|
||||
set: value => {
|
||||
emit('selectAllConversations', value);
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
|
||||
emitter.on(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
|
||||
emitter.on(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
|
||||
emitter.off(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
|
||||
emitter.off(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bulk-action__container">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center justify-between bulk-action__panel">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:checked="allConversationsSelected"
|
||||
:indeterminate.prop="!allConversationsSelected"
|
||||
@change="selectAll($event)"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
|
||||
conversationCount: conversations.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-1 bulk-action__actions">
|
||||
<NextButton
|
||||
v-tooltip="$t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
icon="i-lucide-tags"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="toggleLabelActions"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
|
||||
icon="i-lucide-repeat"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="toggleUpdateActions"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
|
||||
icon="i-lucide-user-round-plus"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="toggleAgentList"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
|
||||
icon="i-lucide-users-round"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="toggleTeamsList"
|
||||
/>
|
||||
</div>
|
||||
<transition name="popover-animation">
|
||||
<LabelActions
|
||||
v-if="showLabelActions"
|
||||
class="label-actions-box"
|
||||
@assign="assignLabels"
|
||||
@close="showLabelActions = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<UpdateActions
|
||||
v-if="showUpdateActions"
|
||||
class="update-actions-box"
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
:show-snooze="!showSnoozedAction"
|
||||
@update="updateConversations"
|
||||
@close="showUpdateActions = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<AgentSelector
|
||||
v-if="showAgentsList"
|
||||
class="agent-actions-box"
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
@select="submit"
|
||||
@close="showAgentsList = false"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="popover-animation">
|
||||
<TeamActions
|
||||
v-if="showTeamsList"
|
||||
class="team-actions-box"
|
||||
@assign-team="assignTeam"
|
||||
@close="showTeamsList = false"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
<div v-if="allConversationsSelected" class="bulk-action__alert">
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
<woot-modal
|
||||
v-model:show="showCustomTimeSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-2"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-show="conversations.length > 0"
|
||||
class="px-2 absolute bottom-4 left-1/2 -translate-x-1/2 z-30 w-full origin-bottom"
|
||||
>
|
||||
<CustomSnoozeModal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="customSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
<div
|
||||
v-if="allConversationsSelected"
|
||||
class="bg-n-amber-2 outline -outline-offset-1 outline-1 outline-n-amber-5 rounded-lg text-sm mb-2 py-1.5 px-2 text-n-amber-text"
|
||||
>
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between p-2 bg-n-button-color outline outline-1 -outline-offset-1 rounded-[10px] outline-n-weak shadow-[0_0_12px_0_rgba(27,40,59,0.08)]"
|
||||
>
|
||||
<div class="ltr:ml-0.5 rtl:mr-0.5 flex items-center gap-1">
|
||||
<label class="cursor-pointer flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="allSelected"
|
||||
:indeterminate="!allConversationsSelected"
|
||||
/>
|
||||
<span class="cursor-pointer">
|
||||
{{
|
||||
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
|
||||
conversationCount: conversations.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg ltr:ml-1 rtl:mr-1" />
|
||||
<NextButton
|
||||
:label="$t('BULK_ACTION.CLEAR_SELECTION')"
|
||||
ghost
|
||||
class="!text-n-blue-11 !px-1 !h-6"
|
||||
sm
|
||||
@click="allSelected = false"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<BulkLabelActions @assign="onAssignLabels" />
|
||||
<BulkUpdateActions
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
:show-snooze="!showSnoozedAction"
|
||||
@update="onUpdateConversations"
|
||||
/>
|
||||
<BulkAgentActions
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
@select="onAssignAgent"
|
||||
/>
|
||||
<BulkTeamActions
|
||||
:conversation-count="conversations.length"
|
||||
@select="onAssignTeam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<woot-modal
|
||||
v-model:show="showCustomTimeSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<CustomSnoozeModal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="customSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__container {
|
||||
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
|
||||
}
|
||||
|
||||
.bulk-action__panel {
|
||||
@apply cursor-pointer;
|
||||
|
||||
span {
|
||||
@apply text-xs my-0 mx-1;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
@apply cursor-pointer m-0;
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-action__alert {
|
||||
@apply bg-n-amber-3 text-n-amber-12 rounded text-xs mt-2 py-1 px-2 border border-solid border-n-amber-5;
|
||||
}
|
||||
|
||||
.popover-animation-enter-active,
|
||||
.popover-animation-leave-active {
|
||||
transition: transform ease-out 0.1s;
|
||||
}
|
||||
|
||||
.popover-animation-enter {
|
||||
transform: scale(0.95);
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.popover-animation-enter-to {
|
||||
transform: scale(1);
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.popover-animation-leave {
|
||||
transform: scale(1);
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.popover-animation-leave-to {
|
||||
transform: scale(0.95);
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.label-actions-box {
|
||||
--triangle-position: 5.3125rem;
|
||||
}
|
||||
.update-actions-box {
|
||||
--triangle-position: 3.5rem;
|
||||
}
|
||||
.agent-actions-box {
|
||||
--triangle-position: 1.75rem;
|
||||
}
|
||||
.team-actions-box {
|
||||
--triangle-position: 0.125rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'assign']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const query = ref('');
|
||||
const selectedLabels = ref([]);
|
||||
|
||||
const filteredLabels = computed(() => {
|
||||
if (!query.value) return labels.value;
|
||||
return labels.value.filter(label =>
|
||||
label.title.toLowerCase().includes(query.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const hasLabels = computed(() => labels.value.length > 0);
|
||||
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
|
||||
|
||||
const isLabelSelected = label => {
|
||||
return selectedLabels.value.includes(label);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedLabels.value.length > 0) {
|
||||
emit('assign', selectedLabels.value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="onClose"
|
||||
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
|
||||
role="dialog"
|
||||
aria-labelledby="label-dialog-title"
|
||||
>
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2.5">
|
||||
<span class="text-sm font-medium">{{
|
||||
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
|
||||
}}</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="flex flex-col max-h-60 min-h-0">
|
||||
<header class="py-2 px-2.5">
|
||||
<Input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
icon-left="i-lucide-search"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</header>
|
||||
<ul
|
||||
v-if="hasLabels"
|
||||
class="flex-1 overflow-y-auto m-0 list-none"
|
||||
role="listbox"
|
||||
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
>
|
||||
<li v-if="!hasFilteredLabels" class="p-2 text-center">
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
|
||||
}}</span>
|
||||
</li>
|
||||
<li
|
||||
v-for="label in filteredLabels"
|
||||
:key="label.id"
|
||||
class="my-1 mx-0 py-0 px-2.5"
|
||||
role="option"
|
||||
:aria-selected="isLabelSelected(label.title)"
|
||||
>
|
||||
<label
|
||||
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
|
||||
>
|
||||
<input
|
||||
v-model="selectedLabels"
|
||||
type="checkbox"
|
||||
:value="label.title"
|
||||
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
|
||||
:aria-label="label.title"
|
||||
/>
|
||||
<span
|
||||
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ label.title }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
|
||||
:style="{ backgroundColor: label.color }"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="p-2 text-center">
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
|
||||
}}</span>
|
||||
</div>
|
||||
<footer class="p-2">
|
||||
<NextButton
|
||||
sm
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.triangle {
|
||||
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
emits: ['assignTeam', 'close'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedteams: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ teams: 'teams/getTeams' }),
|
||||
filteredTeams() {
|
||||
return [
|
||||
{ name: 'None', id: 0 },
|
||||
...this.teams.filter(team =>
|
||||
team.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
),
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
assignTeam(key) {
|
||||
this.$emit('assignTeam', key);
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="onClose" class="bulk-action__teams">
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between header">
|
||||
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="team__list-container">
|
||||
<ul>
|
||||
<li class="search-container">
|
||||
<div
|
||||
class="flex items-center justify-between h-8 gap-2 agent-list-search"
|
||||
>
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="reset-base !outline-0 !text-sm agent--search_input"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<template v-if="filteredTeams.length">
|
||||
<li v-for="team in filteredTeams" :key="team.id">
|
||||
<div class="team__list-item" @click="assignTeam(team)">
|
||||
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
|
||||
{{ team.name }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<li v-else>
|
||||
<div class="team__list-item">
|
||||
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
|
||||
{{ $t('BULK_ACTION.TEAMS.NO_TEAMS_AVAILABLE') }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__teams {
|
||||
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
|
||||
.header {
|
||||
@apply p-2.5;
|
||||
|
||||
span {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply overflow-y-auto max-h-[15rem];
|
||||
.team__list-container {
|
||||
@apply h-full;
|
||||
}
|
||||
.agent-list-search {
|
||||
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
|
||||
.search-icon {
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
|
||||
.agent--search_input {
|
||||
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent w-full h-[unset];
|
||||
}
|
||||
}
|
||||
}
|
||||
.triangle {
|
||||
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
}
|
||||
}
|
||||
ul {
|
||||
@apply m-0 list-none;
|
||||
|
||||
li {
|
||||
&:last-child {
|
||||
.agent-list-item {
|
||||
@apply last:rounded-b-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.team__list-item {
|
||||
@apply flex items-center p-2.5 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
|
||||
span {
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
|
||||
}
|
||||
</style>
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
showResolve: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showReopen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSnooze: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const actions = ref([
|
||||
{ icon: 'i-lucide-check', key: 'resolved' },
|
||||
{ icon: 'i-lucide-redo', key: 'open' },
|
||||
{ icon: 'i-lucide-alarm-clock', key: 'snoozed' },
|
||||
]);
|
||||
|
||||
const updateConversations = key => {
|
||||
if (key === 'snoozed') {
|
||||
// If the user clicks on the snooze option from the bulk action change status dropdown.
|
||||
// Open the snooze option for bulk action in the cmd bar.
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
|
||||
} else {
|
||||
emit('update', key);
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const showAction = key => {
|
||||
const actionsMap = {
|
||||
resolved: props.showResolve,
|
||||
open: props.showReopen,
|
||||
snoozed: props.showSnooze,
|
||||
};
|
||||
return actionsMap[key] || false;
|
||||
};
|
||||
|
||||
const actionLabel = key => {
|
||||
const labelsMap = {
|
||||
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
|
||||
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
|
||||
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
|
||||
};
|
||||
return labelsMap[key] || '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-clickaway="onClose"
|
||||
class="absolute z-20 w-auto origin-top-right border border-solid rounded-lg shadow-md ltr:right-2 rtl:left-2 top-12 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak"
|
||||
>
|
||||
<div
|
||||
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
|
||||
>
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path
|
||||
d="M20 12l-8-8-12 12"
|
||||
fill-rule="evenodd"
|
||||
stroke-width="1px"
|
||||
class="fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="p-2.5 flex gap-1 items-center justify-between">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
|
||||
</span>
|
||||
<Button ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="px-2.5 pt-0 pb-2.5">
|
||||
<WootDropdownMenu class="m-0 list-none">
|
||||
<template v-for="action in actions">
|
||||
<WootDropdownItem v-if="showAction(action.key)" :key="action.key">
|
||||
<Button
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
class="!w-full !justify-start"
|
||||
:icon="action.icon"
|
||||
:label="actionLabel(action.key)"
|
||||
@click="updateConversations(action.key)"
|
||||
/>
|
||||
</WootDropdownItem>
|
||||
</template>
|
||||
</WootDropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,9 +23,15 @@ export function useBulkActions() {
|
||||
|
||||
function deSelectConversation(conversationId, inboxId) {
|
||||
store.dispatch('bulkActions/removeSelectedConversationIds', conversationId);
|
||||
selectedInboxes.value = selectedInboxes.value.filter(
|
||||
item => item !== inboxId
|
||||
);
|
||||
// Only remove one instance of the inboxId, not all
|
||||
// This handles the case where multiple conversations from the same inbox are selected
|
||||
const index = selectedInboxes.value.indexOf(inboxId);
|
||||
if (index > -1) {
|
||||
selectedInboxes.value = [
|
||||
...selectedInboxes.value.slice(0, index),
|
||||
...selectedInboxes.value.slice(index + 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function resetBulkActions() {
|
||||
|
||||
@@ -39,6 +39,7 @@ export default {
|
||||
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
|
||||
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
|
||||
SMALL_SCREEN_BREAKPOINT: 768,
|
||||
LARGE_SCREEN_BREAKPOINT: 1024,
|
||||
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
|
||||
SNOOZE_OPTIONS: {
|
||||
UNTIL_NEXT_REPLY: 'until_next_reply',
|
||||
|
||||
@@ -75,6 +75,11 @@
|
||||
"ADDITIONAL_FILTERS": "Additional filters",
|
||||
"CUSTOM_ATTRIBUTES": "Custom attributes"
|
||||
},
|
||||
"ACTIVE_FILTERS": {
|
||||
"MORE_FILTERS": "+ {count} more filters",
|
||||
"CLEAR_FILTERS": "Clear filters",
|
||||
"EDIT_BUTTON": "Edit"
|
||||
},
|
||||
"CUSTOM_VIEWS": {
|
||||
"ADD": {
|
||||
"TITLE": "Do you want to save this filter?",
|
||||
@@ -95,8 +100,9 @@
|
||||
"EDIT": {
|
||||
"EDIT_BUTTON": "Edit folder"
|
||||
},
|
||||
"SAVE_VIEW": "Save View",
|
||||
"DELETE": {
|
||||
"DELETE_BUTTON": "Delete filter",
|
||||
"DELETE_BUTTON": "Delete",
|
||||
"MODAL": {
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirm deletion",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"BULK_ACTION": {
|
||||
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
|
||||
"CONVERSATIONS_SELECTED": "{conversationCount} selected",
|
||||
"AGENT_SELECT_LABEL": "Select agent",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
|
||||
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
|
||||
"CLEAR_SELECTION": "Clear",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {n} conversation to {agentName}? | Are you sure to assign {n} conversations to {agentName}?",
|
||||
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {n} conversation? | Are you sure to unassign {n} conversations?",
|
||||
"GO_BACK_LABEL": "Go back",
|
||||
"ASSIGN_LABEL": "Assign",
|
||||
"YES": "Yes",
|
||||
"CANCEL": "Cancel",
|
||||
"SEARCH_INPUT_PLACEHOLDER": "Search",
|
||||
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
|
||||
"ASSIGN_TEAM_TOOLTIP": "Assign team",
|
||||
@@ -38,6 +40,8 @@
|
||||
"NONE": "None",
|
||||
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
|
||||
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
|
||||
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {n} conversation to {teamName}? | Are you sure to assign {n} conversations to {teamName}?",
|
||||
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {n} conversation? | Are you sure to unassign {n} conversations?",
|
||||
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign team. Please try again."
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"CHAT_LIST": {
|
||||
"LOADING": "Fetching conversations",
|
||||
"LOAD_MORE_CONVERSATIONS": "Load more conversations",
|
||||
"EOF": "All conversations loaded 🎉",
|
||||
"EOF": "All conversations loaded",
|
||||
"LIST": {
|
||||
"404": "There are no active conversations in this group."
|
||||
},
|
||||
|
||||
@@ -458,6 +458,9 @@
|
||||
"INSTAGRAM": {
|
||||
"PLACEHOLDER": "Add Instagram"
|
||||
},
|
||||
"TELEGRAM": {
|
||||
"PLACEHOLDER": "Add Telegram"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"PLACEHOLDER": "Add TikTok"
|
||||
},
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
|
||||
"CARD": {
|
||||
"SHOW_LABELS": "Show labels",
|
||||
"HIDE_LABELS": "Hide labels"
|
||||
"HIDE_LABELS": "Hide labels",
|
||||
"LABELS_COUNT": "{count} labels"
|
||||
},
|
||||
"VOICE_CALL": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
|
||||
@@ -890,6 +890,20 @@
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Go back"
|
||||
},
|
||||
"UTILITY_ANALYZER": {
|
||||
"ACTION": "Check utility fit",
|
||||
"HELPER_NOTE": "Check this message before submission to improve Utility fit. The system creates a dedicated CSAT template with buttons for reporting and submits it as Utility; Meta may still reclassify it as Marketing based on content.",
|
||||
"RESULT_LABEL": "Meta category prediction",
|
||||
"GUIDANCE_NOTE": "This is a guidance check, not a guarantee of Meta approval.",
|
||||
"SUGGESTION_LABEL": "Suggested utility-safe rewrite",
|
||||
"APPLY": "Use this rewrite",
|
||||
"ERROR_MESSAGE": "Couldn't analyze the message. Please try again.",
|
||||
"CLASSIFICATION": {
|
||||
"LIKELY_UTILITY": "Likely Utility",
|
||||
"LIKELY_MARKETING": "Likely Marketing",
|
||||
"UNCLEAR": "Needs clarification"
|
||||
}
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -901,7 +915,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"WHATSAPP_NOTE": "Note: When you save, the system creates a dedicated CSAT template in WhatsApp (used to capture rating and feedback in reports) and submits it as Utility for approval. Meta may still classify it as Marketing based on content. After approval, surveys are sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
+13
-49
@@ -1,11 +1,10 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
|
||||
import BulkLabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -34,7 +33,6 @@ const { t } = useI18n();
|
||||
|
||||
const selectedCount = computed(() => props.selectedContactIds.length);
|
||||
const totalVisibleContacts = computed(() => props.visibleContactIds.length);
|
||||
const showLabelSelector = ref(false);
|
||||
|
||||
const selectAllLabel = computed(() => {
|
||||
if (!totalVisibleContacts.value) {
|
||||
@@ -73,22 +71,11 @@ const selectionModel = computed({
|
||||
});
|
||||
|
||||
const emitClearSelection = () => {
|
||||
showLabelSelector.value = false;
|
||||
emit('clearSelection');
|
||||
};
|
||||
|
||||
const toggleLabelSelector = () => {
|
||||
if (!selectedCount.value || props.isLoading) return;
|
||||
showLabelSelector.value = !showLabelSelector.value;
|
||||
};
|
||||
|
||||
const closeLabelSelector = () => {
|
||||
showLabelSelector.value = false;
|
||||
};
|
||||
|
||||
const handleAssignLabels = labels => {
|
||||
emit('assignLabels', labels);
|
||||
closeLabelSelector();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -107,56 +94,33 @@ const handleAssignLabels = labels => {
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
|
||||
class="!px-1.5"
|
||||
class="!px-1"
|
||||
@click="emitClearSelection"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<div
|
||||
v-on-click-outside="closeLabelSelector"
|
||||
class="relative flex items-center"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-tags"
|
||||
:label="t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS')"
|
||||
:disabled="!selectedCount || isLoading"
|
||||
:is-loading="isLoading"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="toggleLabelSelector"
|
||||
/>
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<LabelActions
|
||||
v-if="showLabelSelector"
|
||||
class="[&>.triangle]:!hidden [&>div>button]:!hidden ltr:!right-0 rtl:!left-0 top-8 mt-0.5"
|
||||
@assign="handleAssignLabels"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
<BulkLabelActions
|
||||
type="contact"
|
||||
:is-loading="isLoading"
|
||||
:disabled="!selectedCount"
|
||||
class="[&>button]:!text-n-blue-11 [&>button>span]:!text-n-blue-11 [&>button]:!px-2"
|
||||
@assign="handleAssignLabels"
|
||||
/>
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg" />
|
||||
<Policy :permissions="['administrator']">
|
||||
<Button
|
||||
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
sm
|
||||
faded
|
||||
ghost
|
||||
ruby
|
||||
icon="i-lucide-trash"
|
||||
:label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:aria-label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:disabled="!selectedCount || isLoading"
|
||||
:is-loading="isLoading"
|
||||
class="!px-1.5 [&>span:nth-child(2)]:hidden"
|
||||
class="!px-2 [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
|
||||
@click="emit('deleteSelected')"
|
||||
/>
|
||||
</Policy>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { computed, watch, onMounted, onBeforeMount } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import ChatList from '../../../components/ChatList.vue';
|
||||
import ConversationBox from '../../../components/widgets/conversation/ConversationBox.vue';
|
||||
import ChatList from 'dashboard/components/ChatList.vue';
|
||||
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
|
||||
@@ -11,187 +14,144 @@ import { emitter } from 'shared/helpers/mitt';
|
||||
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
|
||||
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatList,
|
||||
ConversationBox,
|
||||
CmdBarConversationSnooze,
|
||||
SidepanelSwitch,
|
||||
ConversationSidebar,
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
beforeRouteLeave(to, from, next) {
|
||||
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
|
||||
// and resolves timing issues during navigation with conversation view and other screens
|
||||
if (this.conversationId) {
|
||||
this.$store.dispatch('clearSelectedState');
|
||||
}
|
||||
next(); // Continue with navigation
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
props: {
|
||||
inboxId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
teamId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
foldersId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
setup() {
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const { accountId } = useAccount();
|
||||
teamId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
foldersId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
accountId,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showSearchModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
chatList: 'getAllConversations',
|
||||
currentChat: 'getSelectedChat',
|
||||
}),
|
||||
showConversationList() {
|
||||
return this.isOnExpandedLayout ? !this.conversationId : true;
|
||||
},
|
||||
showMessageView() {
|
||||
return this.conversationId ? true : !this.isOnExpandedLayout;
|
||||
},
|
||||
isOnExpandedLayout() {
|
||||
const {
|
||||
LAYOUT_TYPES: { CONDENSED },
|
||||
} = wootConstants;
|
||||
const { conversation_display_type: conversationDisplayType = CONDENSED } =
|
||||
this.uiSettings;
|
||||
return conversationDisplayType !== CONDENSED;
|
||||
},
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
shouldShowSidebar() {
|
||||
if (!this.currentChat.id) {
|
||||
return false;
|
||||
}
|
||||
const chatList = useMapGetter('getAllConversations');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
|
||||
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
|
||||
return isContactSidebarOpen;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
conversationId() {
|
||||
this.fetchConversationIfUnavailable();
|
||||
},
|
||||
},
|
||||
const isOnExpandedLayout = computed(() => {
|
||||
const {
|
||||
LAYOUT_TYPES: { CONDENSED },
|
||||
} = wootConstants;
|
||||
const { conversation_display_type: conversationDisplayType = CONDENSED } =
|
||||
uiSettings.value;
|
||||
return conversationDisplayType !== CONDENSED;
|
||||
});
|
||||
|
||||
created() {
|
||||
// Clear selected state early if no conversation is selected
|
||||
// This prevents child components from accessing stale data
|
||||
// and resolves timing issues during navigation
|
||||
// with conversation view and other screens
|
||||
if (!this.conversationId) {
|
||||
this.$store.dispatch('clearSelectedState');
|
||||
}
|
||||
},
|
||||
const showConversationList = computed(() =>
|
||||
isOnExpandedLayout.value ? !props.conversationId : true
|
||||
);
|
||||
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
this.$store.dispatch('portals/index');
|
||||
this.initialize();
|
||||
this.$watch('$store.state.route', () => this.initialize());
|
||||
this.$watch('chatList.length', () => {
|
||||
this.setActiveChat();
|
||||
});
|
||||
},
|
||||
const showMessageView = computed(() =>
|
||||
props.conversationId ? true : !isOnExpandedLayout.value
|
||||
);
|
||||
|
||||
methods: {
|
||||
onConversationLoad() {
|
||||
this.fetchConversationIfUnavailable();
|
||||
},
|
||||
initialize() {
|
||||
this.$store.dispatch('setActiveInbox', this.inboxId);
|
||||
this.setActiveChat();
|
||||
},
|
||||
toggleConversationLayout() {
|
||||
const { LAYOUT_TYPES } = wootConstants;
|
||||
const {
|
||||
conversation_display_type:
|
||||
conversationDisplayType = LAYOUT_TYPES.CONDENSED,
|
||||
} = this.uiSettings;
|
||||
const newViewType =
|
||||
conversationDisplayType === LAYOUT_TYPES.CONDENSED
|
||||
? LAYOUT_TYPES.EXPANDED
|
||||
: LAYOUT_TYPES.CONDENSED;
|
||||
this.updateUISettings({
|
||||
conversation_display_type: newViewType,
|
||||
previously_used_conversation_display_type: newViewType,
|
||||
});
|
||||
},
|
||||
fetchConversationIfUnavailable() {
|
||||
if (!this.conversationId) {
|
||||
return;
|
||||
}
|
||||
const chat = this.findConversation();
|
||||
if (!chat) {
|
||||
this.$store.dispatch('getConversation', this.conversationId);
|
||||
}
|
||||
},
|
||||
findConversation() {
|
||||
const conversationId = parseInt(this.conversationId, 10);
|
||||
const [chat] = this.chatList.filter(c => c.id === conversationId);
|
||||
return chat;
|
||||
},
|
||||
setActiveChat() {
|
||||
if (this.conversationId) {
|
||||
const selectedConversation = this.findConversation();
|
||||
// If conversation doesn't exist or selected conversation is same as the active
|
||||
// conversation, don't set active conversation.
|
||||
if (
|
||||
!selectedConversation ||
|
||||
selectedConversation.id === this.currentChat.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { messageId } = this.$route.query;
|
||||
this.$store
|
||||
.dispatch('setActiveChat', {
|
||||
data: selectedConversation,
|
||||
after: messageId,
|
||||
})
|
||||
.then(() => {
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
|
||||
});
|
||||
} else {
|
||||
this.$store.dispatch('clearSelectedState');
|
||||
}
|
||||
},
|
||||
onSearch() {
|
||||
this.showSearchModal = true;
|
||||
},
|
||||
closeSearch() {
|
||||
this.showSearchModal = false;
|
||||
},
|
||||
},
|
||||
const shouldShowSidebar = computed(() => {
|
||||
if (!currentChat.value.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
|
||||
return isContactSidebarOpen;
|
||||
});
|
||||
|
||||
const findConversation = () => {
|
||||
const conversationId = parseInt(props.conversationId, 10);
|
||||
const [chat] = chatList.value.filter(c => c.id === conversationId);
|
||||
return chat;
|
||||
};
|
||||
|
||||
const fetchConversationIfUnavailable = () => {
|
||||
if (!props.conversationId) {
|
||||
return;
|
||||
}
|
||||
const chat = findConversation();
|
||||
if (!chat) {
|
||||
store.dispatch('getConversation', props.conversationId);
|
||||
}
|
||||
};
|
||||
|
||||
const setActiveChat = () => {
|
||||
if (props.conversationId) {
|
||||
const selectedConversation = findConversation();
|
||||
// If conversation doesn't exist or selected conversation is same as the active
|
||||
// conversation, don't set active conversation.
|
||||
if (
|
||||
!selectedConversation ||
|
||||
selectedConversation.id === currentChat.value.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { messageId } = route.query;
|
||||
store
|
||||
.dispatch('setActiveChat', {
|
||||
data: selectedConversation,
|
||||
after: messageId,
|
||||
})
|
||||
.then(() => {
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
|
||||
});
|
||||
} else {
|
||||
store.dispatch('clearSelectedState');
|
||||
}
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
store.dispatch('setActiveInbox', props.inboxId);
|
||||
setActiveChat();
|
||||
};
|
||||
|
||||
const onConversationLoad = () => {
|
||||
fetchConversationIfUnavailable();
|
||||
};
|
||||
|
||||
onBeforeRouteLeave((to, from, next) => {
|
||||
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
|
||||
// and resolves timing issues during navigation with conversation view and other screens
|
||||
if (props.conversationId) {
|
||||
store.dispatch('clearSelectedState');
|
||||
}
|
||||
next(); // Continue with navigation
|
||||
});
|
||||
|
||||
watch(() => props.conversationId, fetchConversationIfUnavailable);
|
||||
watch(() => store.state.route, initialize);
|
||||
watch(() => chatList.value.length, setActiveChat);
|
||||
|
||||
onBeforeMount(() => {
|
||||
// Clear selected state early if no conversation is selected
|
||||
// This prevents child components from accessing stale data
|
||||
// and resolves timing issues during navigation
|
||||
// with conversation view and other screens
|
||||
if (!props.conversationId) {
|
||||
store.dispatch('clearSelectedState');
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('agents/get');
|
||||
store.dispatch('portals/index');
|
||||
initialize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -58,12 +58,14 @@ export default {
|
||||
twitter: '',
|
||||
linkedin: '',
|
||||
github: '',
|
||||
telegram: '',
|
||||
},
|
||||
socialProfileKeys: [
|
||||
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
|
||||
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
|
||||
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
|
||||
{ key: 'github', prefixURL: 'https://github.com/' },
|
||||
{ key: 'telegram', prefixURL: 'https://t.me/' },
|
||||
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
|
||||
],
|
||||
};
|
||||
@@ -175,12 +177,14 @@ export default {
|
||||
const {
|
||||
social_profiles: socialProfiles = {},
|
||||
screen_name: twitterScreenName,
|
||||
social_telegram_user_name: telegramUserName,
|
||||
} = additionalAttributes;
|
||||
this.socialProfileUserNames = {
|
||||
twitter: socialProfiles.twitter || twitterScreenName || '',
|
||||
facebook: socialProfiles.facebook || '',
|
||||
linkedin: socialProfiles.linkedin || '',
|
||||
github: socialProfiles.github || '',
|
||||
telegram: socialProfiles.telegram || telegramUserName || '',
|
||||
instagram: socialProfiles.instagram || '',
|
||||
tiktok: socialProfiles.tiktok || '',
|
||||
};
|
||||
|
||||
@@ -81,10 +81,14 @@ export default {
|
||||
screen_name: twitterScreenName,
|
||||
social_telegram_user_name: telegramUsername,
|
||||
} = this.additionalAttributes;
|
||||
|
||||
const telegram = socialProfiles?.telegram || telegramUsername || '';
|
||||
const twitter = socialProfiles?.twitter || twitterScreenName || '';
|
||||
|
||||
return {
|
||||
twitter: twitterScreenName,
|
||||
telegram: telegramUsername,
|
||||
...(socialProfiles || {}),
|
||||
twitter,
|
||||
telegram,
|
||||
};
|
||||
},
|
||||
// Delete Modal
|
||||
|
||||
@@ -23,15 +23,15 @@ export default {
|
||||
<template>
|
||||
<NextButton
|
||||
v-tooltip.left="$t('CONVERSATION.SWITCH_VIEW_LAYOUT')"
|
||||
:icon="
|
||||
isOnExpandedLayout
|
||||
? 'i-lucide-arrow-left-to-line'
|
||||
: 'i-lucide-arrow-right-to-line'
|
||||
"
|
||||
icon="i-woot-expand-list"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0 md:inline-flex hidden"
|
||||
sm
|
||||
ghost
|
||||
class="flex-shrink-0 md:inline-flex hidden"
|
||||
:class="{
|
||||
'ltr:!rotate-180 rtl:!rotate-0': isOnExpandedLayout,
|
||||
'ltr:!rotate-0 rtl:!rotate-180': !isOnExpandedLayout,
|
||||
}"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -182,6 +182,7 @@ onMounted(() => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[bot.id]"
|
||||
@click="openDeletePopup(bot)"
|
||||
/>
|
||||
|
||||
@@ -268,6 +268,7 @@ const confirmDeletion = () => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[agent.id]"
|
||||
@click="openDeletePopup(agent, index)"
|
||||
/>
|
||||
|
||||
@@ -70,14 +70,6 @@ const automationActive = computed({
|
||||
:is-loading="loading"
|
||||
@click="$emit('edit', automation)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
|
||||
:is-loading="loading"
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
@click="$emit('delete', automation)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="$t('AUTOMATION.CLONE.TOOLTIP')"
|
||||
icon="i-woot-clone"
|
||||
@@ -86,6 +78,15 @@ const automationActive = computed({
|
||||
:is-loading="loading"
|
||||
@click="$emit('clone', automation)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
|
||||
:is-loading="loading"
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="$emit('delete', automation)"
|
||||
/>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
|
||||
@@ -232,6 +232,7 @@ const tableHeaders = computed(() => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[cannedItem.id]"
|
||||
@click="openDeletePopup(cannedItem)"
|
||||
/>
|
||||
|
||||
+1
@@ -66,6 +66,7 @@ const getFormattedPermissions = role => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[customRole.id]"
|
||||
@click="emit('delete', customRole)"
|
||||
/>
|
||||
|
||||
@@ -116,17 +116,15 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full p-8 overflow-auto">
|
||||
<div
|
||||
class="grid max-w-3xl grid-cols-1 xs:grid-cols-2 mx-0 gap-6 sm:grid-cols-3"
|
||||
>
|
||||
<ChannelItem
|
||||
v-for="channel in channelList"
|
||||
:key="channel.key"
|
||||
:channel="channel"
|
||||
:enabled-features="enabledFeatures"
|
||||
@channel-item-click="initChannelAuth"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="grid max-w-3xl grid-cols-1 xs:grid-cols-2 mx-0 gap-6 sm:grid-cols-3 p-8"
|
||||
>
|
||||
<ChannelItem
|
||||
v-for="channel in channelList"
|
||||
:key="channel.key"
|
||||
:channel="channel"
|
||||
:enabled-features="enabledFeatures"
|
||||
@channel-item-click="initChannelAuth"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="address"
|
||||
:class="{ error: v$.address.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.ADDRESS.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.ADDRESS.PLACE_HOLDER')"
|
||||
@blur="v$.address.$touch"
|
||||
@@ -124,7 +124,7 @@ export default {
|
||||
v-model="port"
|
||||
type="number"
|
||||
:class="{ error: v$.port.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.PORT.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.PORT.PLACE_HOLDER')"
|
||||
@blur="v$.port.$touch"
|
||||
@@ -132,7 +132,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="login"
|
||||
:class="{ error: v$.login.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.LOGIN.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.LOGIN.PLACE_HOLDER')"
|
||||
@blur="v$.login.$touch"
|
||||
@@ -140,7 +140,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="password"
|
||||
:class="{ error: v$.password.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.PASSWORD.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.PASSWORD.PLACE_HOLDER')"
|
||||
type="password"
|
||||
|
||||
@@ -62,14 +62,14 @@ const items = computed(() => {
|
||||
<div class="mx-auto flex flex-col gap-6 mb-8 max-w-7xl w-full !px-6">
|
||||
<PageHeader class="block lg:hidden !mb-0" :header-title="pageTitle" />
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[52rem]"
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak h-full min-h-[50dvh]"
|
||||
>
|
||||
<woot-wizard
|
||||
class="hidden lg:block col-span-2 h-fit py-8 px-6"
|
||||
:global-config="globalConfig"
|
||||
:items="items"
|
||||
/>
|
||||
<div class="col-span-6 overflow-hidden">
|
||||
<div class="col-span-6 flex flex-col overflow-y-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,6 +170,7 @@ const openDelete = inbox => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="openDelete(inbox)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -214,6 +214,18 @@ export default {
|
||||
const { medium, channel_type: type } = this.inbox;
|
||||
return getInboxIconByType(type, medium, 'line');
|
||||
},
|
||||
bannerMaxWidth() {
|
||||
const narrowTabs = [
|
||||
'collaborators',
|
||||
'configuration',
|
||||
'bot-configuration',
|
||||
];
|
||||
if (narrowTabs.includes(this.selectedTabKey)) return 'max-w-4xl';
|
||||
if (this.selectedTabKey === 'inbox-settings') {
|
||||
return this.isAWebWidgetInbox ? 'max-w-7xl' : 'max-w-4xl';
|
||||
}
|
||||
return 'max-w-7xl';
|
||||
},
|
||||
inboxName() {
|
||||
if (this.isATwilioSMSChannel || this.isATwilioWhatsAppChannel) {
|
||||
return `${this.inbox.name} (${
|
||||
@@ -571,44 +583,57 @@ export default {
|
||||
v-if="microsoftUnauthorized"
|
||||
:inbox="inbox"
|
||||
class="mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<FacebookReauthorize
|
||||
v-if="facebookUnauthorized"
|
||||
:inbox="inbox"
|
||||
class="mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<GoogleReauthorize
|
||||
v-if="googleUnauthorized"
|
||||
:inbox="inbox"
|
||||
class="mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<InstagramReauthorize
|
||||
v-if="instagramUnauthorized"
|
||||
:inbox="inbox"
|
||||
class="mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<TiktokReauthorize
|
||||
v-if="tiktokUnauthorized"
|
||||
:inbox="inbox"
|
||||
class="mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<WhatsappReauthorize
|
||||
v-if="whatsappUnauthorized"
|
||||
:whatsapp-registration-incomplete="whatsappRegistrationIncomplete"
|
||||
:inbox="inbox"
|
||||
class="mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<DuplicateInboxBanner
|
||||
v-if="hasDuplicateInstagramInbox"
|
||||
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
|
||||
class="mx-6 mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="selectedTabKey === 'inbox-settings'"
|
||||
class="flex flex-col md:flex-row items-center lg:items-start justify-between gap-5 lg:gap-10 mx-6"
|
||||
>
|
||||
<div class="max-w-2xl flex-1 flex flex-col min-w-0">
|
||||
<div
|
||||
class="flex-1 flex flex-col min-w-0"
|
||||
:class="{
|
||||
'max-w-2xl': isAWebWidgetInbox,
|
||||
'max-w-4xl': !isAWebWidgetInbox,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-1 items-start mb-4">
|
||||
<label class="text-heading-3 text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL') }}
|
||||
@@ -755,6 +780,7 @@ export default {
|
||||
<SenderNameExamplePreview
|
||||
:sender-name-type="senderNameType"
|
||||
:business-name="businessName"
|
||||
:is-website-channel="isAWebWidgetInbox"
|
||||
@update="toggleSenderNameType"
|
||||
/>
|
||||
</template>
|
||||
@@ -1107,10 +1133,10 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTabKey === 'collaborators'" class="mx-6 max-w-3xl">
|
||||
<div v-if="selectedTabKey === 'collaborators'" class="mx-6 max-w-4xl">
|
||||
<CollaboratorsPage :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'configuration'">
|
||||
<div v-if="selectedTabKey === 'configuration'" class="mx-6 max-w-4xl">
|
||||
<ConfigurationPage :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'csat'">
|
||||
|
||||
@@ -147,7 +147,9 @@ export default {
|
||||
await this.$store.dispatch('inboxes/updateInboxSMTP', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE'));
|
||||
useAlert(
|
||||
error.message || this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -175,7 +177,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="address"
|
||||
:class="{ error: v$.address.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.ADDRESS.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.ADDRESS.PLACE_HOLDER')"
|
||||
@blur="v$.address.$touch"
|
||||
@@ -184,7 +186,7 @@ export default {
|
||||
v-model="port"
|
||||
type="number"
|
||||
:class="{ error: v$.port.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.PORT.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.PORT.PLACE_HOLDER')"
|
||||
@blur="v$.port.$touch"
|
||||
@@ -192,7 +194,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="login"
|
||||
:class="{ error: v$.login.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.LOGIN.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.LOGIN.PLACE_HOLDER')"
|
||||
@blur="v$.login.$touch"
|
||||
@@ -200,7 +202,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="password"
|
||||
:class="{ error: v$.password.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.PASSWORD.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.PASSWORD.PLACE_HOLDER')"
|
||||
type="password"
|
||||
@@ -209,7 +211,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="domain"
|
||||
:class="{ error: v$.domain.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.DOMAIN.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.DOMAIN.PLACE_HOLDER')"
|
||||
@blur="v$.domain.$touch"
|
||||
@@ -220,14 +222,14 @@ export default {
|
||||
:action="handleEncryptionChange"
|
||||
/>
|
||||
<SingleSelectDropdown
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.OPEN_SSL_VERIFY_MODE')"
|
||||
:selected="openSSLVerifyMode"
|
||||
:options="openSSLVerifyModes"
|
||||
:action="handleSSLModeChange"
|
||||
/>
|
||||
<SingleSelectDropdown
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.AUTH_MECHANISM')"
|
||||
:selected="authMechanism"
|
||||
:options="authMechanisms"
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-6 max-w-3xl">
|
||||
<div class="mx-6 max-w-4xl">
|
||||
<LoadingState v-if="uiFlags.isFetching || uiFlags.isFetchingAgentBot" />
|
||||
<form v-else @submit.prevent="updateActiveAgentBot">
|
||||
<SettingsFieldSection
|
||||
|
||||
+8
-1
@@ -13,6 +13,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isWebsiteChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
@@ -56,7 +60,10 @@ const toggleSenderNameType = key => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row md:flex-col xl:flex-row items-start gap-4 mt-3 min-w-0"
|
||||
class="flex flex-col items-start gap-4 mt-3 min-w-0"
|
||||
:class="
|
||||
isWebsiteChannel ? 'sm:flex-row md:flex-col xl:flex-row' : 'sm:flex-row'
|
||||
"
|
||||
>
|
||||
<RadioCard
|
||||
v-for="keyOption in senderNameKeyOptions"
|
||||
|
||||
+7
-7
@@ -149,7 +149,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isATwilioChannel" class="mx-6">
|
||||
<div v-if="isATwilioChannel">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.TITLE')"
|
||||
:help-text="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE')"
|
||||
@@ -168,7 +168,7 @@ export default {
|
||||
</NextButton>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<div v-else-if="isAVoiceChannel" class="mx-6">
|
||||
<div v-else-if="isAVoiceChannel">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||
:help-text="
|
||||
@@ -187,7 +187,7 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isALineChannel" class="mx-6">
|
||||
<div v-else-if="isALineChannel">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
|
||||
:help-text="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE')"
|
||||
@@ -196,7 +196,7 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<div v-else-if="isAWebWidgetInbox">
|
||||
<div class="mx-6">
|
||||
<div>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
|
||||
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.SUBTITLE')"
|
||||
@@ -266,7 +266,7 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="isAPIInbox" class="mx-6">
|
||||
<div v-else-if="isAPIInbox">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER')"
|
||||
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER_SUB_TEXT')"
|
||||
@@ -297,7 +297,7 @@ export default {
|
||||
</div>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<div v-else-if="isAnEmailChannel" class="mx-6">
|
||||
<div v-else-if="isAnEmailChannel">
|
||||
<div>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.FORWARD_EMAIL_TITLE')"
|
||||
@@ -325,7 +325,7 @@ export default {
|
||||
<SmtpSettings v-if="inbox.imap_enabled" :inbox="inbox" />
|
||||
</div>
|
||||
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
|
||||
<div v-if="inbox.provider_config" class="mx-6">
|
||||
<div v-if="inbox.provider_config">
|
||||
<!-- Embedded Signup Section -->
|
||||
<template v-if="isEmbeddedSignupWhatsApp">
|
||||
<SettingsFieldSection
|
||||
|
||||
+151
-1
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
@@ -26,6 +27,7 @@ const props = defineProps({
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const { captainEnabled } = useCaptain();
|
||||
|
||||
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
|
||||
props.inbox?.id
|
||||
@@ -37,6 +39,8 @@ const isAnyWhatsAppChannel = computed(
|
||||
);
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const utilityAnalysisLoading = ref(false);
|
||||
const utilityAnalysisResult = ref(null);
|
||||
const selectedLabelValues = ref([]);
|
||||
const currentLabel = ref('');
|
||||
|
||||
@@ -46,7 +50,7 @@ const state = reactive({
|
||||
message: '',
|
||||
templateButtonText: 'Please rate us',
|
||||
surveyRuleOperator: 'contains',
|
||||
templateLanguage: '',
|
||||
templateLanguage: 'en',
|
||||
});
|
||||
|
||||
const templateStatus = ref(null);
|
||||
@@ -89,6 +93,9 @@ const messagePreviewData = computed(() => ({
|
||||
const shouldShowTemplateStatus = computed(
|
||||
() => templateStatus.value && !templateLoading.value
|
||||
);
|
||||
const showUtilityAnalyzer = computed(
|
||||
() => isAnyWhatsAppChannel.value && captainEnabled.value
|
||||
);
|
||||
|
||||
const templateApprovalStatus = computed(() => {
|
||||
const statusMap = {
|
||||
@@ -218,6 +225,85 @@ const updateDisplayType = type => {
|
||||
state.displayType = type;
|
||||
};
|
||||
|
||||
const resetUtilityAnalysis = () => {
|
||||
utilityAnalysisResult.value = null;
|
||||
};
|
||||
|
||||
const analyzeTemplateUtility = async () => {
|
||||
if (!showUtilityAnalyzer.value || !state.message?.trim()) return;
|
||||
|
||||
utilityAnalysisLoading.value = true;
|
||||
resetUtilityAnalysis();
|
||||
|
||||
try {
|
||||
const response = await store.dispatch(
|
||||
'inboxes/analyzeCSATTemplateUtility',
|
||||
{
|
||||
inboxId: props.inbox.id,
|
||||
template: {
|
||||
message: state.message,
|
||||
button_text: state.templateButtonText,
|
||||
language: state.templateLanguage,
|
||||
},
|
||||
}
|
||||
);
|
||||
utilityAnalysisResult.value = response;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.response?.data?.error ||
|
||||
t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
utilityAnalysisLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyUtilitySuggestion = () => {
|
||||
const suggestion = utilityAnalysisResult.value?.optimized_message;
|
||||
if (!suggestion) return;
|
||||
|
||||
state.message = suggestion;
|
||||
resetUtilityAnalysis();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [state.message, state.templateButtonText, state.templateLanguage],
|
||||
(newValues, oldValues) => {
|
||||
if (!oldValues || !utilityAnalysisResult.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = newValues.some(
|
||||
(value, index) => value !== oldValues[index]
|
||||
);
|
||||
if (changed) {
|
||||
resetUtilityAnalysis();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const getUtilityClassificationLabel = classification => {
|
||||
if (classification === 'LIKELY_UTILITY') {
|
||||
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_UTILITY');
|
||||
}
|
||||
if (classification === 'LIKELY_MARKETING') {
|
||||
return t(
|
||||
'INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_MARKETING'
|
||||
);
|
||||
}
|
||||
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.UNCLEAR');
|
||||
};
|
||||
|
||||
const getUtilityClassificationClass = classification => {
|
||||
if (classification === 'LIKELY_UTILITY') {
|
||||
return 'bg-n-teal-3 text-n-teal-11';
|
||||
}
|
||||
if (classification === 'LIKELY_MARKETING') {
|
||||
return 'bg-n-ruby-3 text-n-ruby-11';
|
||||
}
|
||||
return 'bg-n-amber-3 text-n-amber-11';
|
||||
};
|
||||
|
||||
const updateSurveyRuleOperator = operator => {
|
||||
state.surveyRuleOperator = operator;
|
||||
};
|
||||
@@ -450,6 +536,70 @@ const handleConfirmTemplateUpdate = async () => {
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
<div v-if="showUtilityAnalyzer" class="flex flex-col gap-2">
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ACTION')"
|
||||
:is-loading="utilityAnalysisLoading"
|
||||
:disabled="!state.message?.trim()"
|
||||
@click="analyzeTemplateUtility"
|
||||
/>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.HELPER_NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="utilityAnalysisResult"
|
||||
class="flex flex-col gap-3 p-3 rounded-xl outline outline-1 outline-n-weak bg-n-alpha-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.RESULT_LABEL') }}
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="
|
||||
getUtilityClassificationClass(
|
||||
utilityAnalysisResult.classification
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
getUtilityClassificationLabel(
|
||||
utilityAnalysisResult.classification
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.GUIDANCE_NOTE') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="
|
||||
utilityAnalysisResult.optimized_message &&
|
||||
utilityAnalysisResult.classification !== 'LIKELY_UTILITY'
|
||||
"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<p class="text-xs font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.SUGGESTION_LABEL')
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-n-slate-12">
|
||||
{{ utilityAnalysisResult.optimized_message }}
|
||||
</p>
|
||||
<NextButton
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.APPLY')"
|
||||
@click="applyUtilitySuggestion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
v-model="state.templateButtonText"
|
||||
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ defineEmits(['edit', 'delete']);
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="$emit('delete', app)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
@@ -133,6 +133,7 @@ const inboxName = hook => (hook.inbox ? hook.inbox.name : '');
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="$emit('delete', hook)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
@@ -73,6 +73,7 @@ const subscribedEvents = computed(() => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="emit('delete', webhook, index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -174,6 +174,7 @@ onBeforeMount(() => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[label.id]"
|
||||
@click="openDeletePopup(label)"
|
||||
/>
|
||||
|
||||
@@ -96,6 +96,7 @@ const visibilityLabel = computed(() => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="$emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -288,6 +288,7 @@ export default {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[sla.id]"
|
||||
@click="openDeletePopup(sla)"
|
||||
/>
|
||||
|
||||
@@ -88,19 +88,14 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-6 col-span-6">
|
||||
<form
|
||||
class="flex flex-wrap mx-0 overflow-x-auto"
|
||||
@submit.prevent="addAgents"
|
||||
>
|
||||
<div class="w-full">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
:header-content="$t('TEAMS_SETTINGS.ADD.DESC')"
|
||||
/>
|
||||
</div>
|
||||
<div class="h-full w-full p-8 col-span-6 overflow-auto">
|
||||
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
:header-content="$t('TEAMS_SETTINGS.ADD.DESC')"
|
||||
/>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="w-full h-full">
|
||||
<div v-if="v$.selectedAgents.$error">
|
||||
<p class="error-message pb-2">
|
||||
{{ $t('TEAMS_SETTINGS.ADD.AGENT_VALIDATION_ERROR') }}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-6 col-span-6">
|
||||
<div class="h-full w-full p-6 col-span-6 overflow-y-auto">
|
||||
<PageHeader
|
||||
:header-title="$t('TEAMS_SETTINGS.CREATE_FLOW.CREATE.TITLE')"
|
||||
:header-content="$t('TEAMS_SETTINGS.CREATE_FLOW.CREATE.DESC')"
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 mb-8 max-w-7xl mx-auto w-full !px-6">
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[43rem] w-full"
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak h-full min-h-[50dvh] w-full"
|
||||
>
|
||||
<woot-wizard
|
||||
class="hidden lg:block col-span-2 h-fit py-8 px-6"
|
||||
|
||||
@@ -103,19 +103,14 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-8 col-span-6">
|
||||
<form
|
||||
class="flex flex-wrap mx-0 overflow-x-auto"
|
||||
@submit.prevent="addAgents"
|
||||
>
|
||||
<div class="w-full">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
:header-content="$t('TEAMS_SETTINGS.EDIT_FLOW.AGENTS.DESC')"
|
||||
/>
|
||||
</div>
|
||||
<div class="h-full w-full p-8 col-span-6 overflow-auto">
|
||||
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
:header-content="$t('TEAMS_SETTINGS.EDIT_FLOW.AGENTS.DESC')"
|
||||
/>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="w-full h-full">
|
||||
<div v-if="v$.selectedAgents.$error">
|
||||
<p class="error-message pb-2">
|
||||
{{ $t('TEAMS_SETTINGS.ADD.AGENT_VALIDATION_ERROR') }}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-8 col-span-6">
|
||||
<div class="h-full w-full p-8 col-span-6 overflow-y-auto">
|
||||
<PageHeader
|
||||
:header-title="$t('TEAMS_SETTINGS.EDIT_FLOW.CREATE.TITLE')"
|
||||
:header-content="$t('TEAMS_SETTINGS.EDIT_FLOW.CREATE.DESC')"
|
||||
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 mb-8 max-w-7xl mx-auto w-full !px-6">
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[43rem] w-full"
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak h-full min-h-[50dvh] w-full"
|
||||
>
|
||||
<woot-wizard
|
||||
class="hidden lg:block col-span-2 h-fit py-8 px-6"
|
||||
|
||||
@@ -161,6 +161,7 @@ const confirmPlaceHolderText = computed(() =>
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[team.id]"
|
||||
@click="openDelete(team)"
|
||||
/>
|
||||
|
||||
@@ -360,6 +360,13 @@ export const actions = {
|
||||
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
|
||||
return response.data;
|
||||
},
|
||||
analyzeCSATTemplateUtility: async (_, { inboxId, template }) => {
|
||||
const response = await InboxesAPI.analyzeCSATTemplateUtility(
|
||||
inboxId,
|
||||
template
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
|
||||
@@ -361,4 +361,77 @@ describe('useNumberFormatter', () => {
|
||||
expect(result).toMatch(/1[.,\s]000[.,\s]000/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCompactWithDecimal', () => {
|
||||
it('should return exact numbers for values under 1,000', () => {
|
||||
const { formatCompactWithDecimal } = useNumberFormatter();
|
||||
expect(formatCompactWithDecimal(0)).toBe('0');
|
||||
expect(formatCompactWithDecimal(1)).toBe('1');
|
||||
expect(formatCompactWithDecimal(42)).toBe('42');
|
||||
expect(formatCompactWithDecimal(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should return compact format with decimals for thousands', () => {
|
||||
const { formatCompactWithDecimal } = useNumberFormatter();
|
||||
expect(formatCompactWithDecimal(1000)).toBe('1K');
|
||||
expect(formatCompactWithDecimal(1500)).toBe('1.5K');
|
||||
expect(formatCompactWithDecimal(29400)).toBe('29.4K');
|
||||
expect(formatCompactWithDecimal(15000)).toBe('15K');
|
||||
expect(formatCompactWithDecimal(15500)).toBe('15.5K');
|
||||
});
|
||||
|
||||
it('should return compact format with decimals for millions', () => {
|
||||
const { formatCompactWithDecimal } = useNumberFormatter();
|
||||
expect(formatCompactWithDecimal(1000000)).toBe('1M');
|
||||
expect(formatCompactWithDecimal(1200000)).toBe('1.2M');
|
||||
expect(formatCompactWithDecimal(1234000)).toBe('1.2M');
|
||||
expect(formatCompactWithDecimal(2500000)).toBe('2.5M');
|
||||
expect(formatCompactWithDecimal(10000000)).toBe('10M');
|
||||
});
|
||||
|
||||
it('should handle edge cases gracefully', () => {
|
||||
const { formatCompactWithDecimal } = useNumberFormatter();
|
||||
expect(formatCompactWithDecimal(null)).toBe('0');
|
||||
expect(formatCompactWithDecimal(undefined)).toBe('0');
|
||||
expect(formatCompactWithDecimal(NaN)).toBe('0');
|
||||
expect(formatCompactWithDecimal('string')).toBe('0');
|
||||
});
|
||||
|
||||
it('should handle negative numbers', () => {
|
||||
const { formatCompactWithDecimal } = useNumberFormatter();
|
||||
expect(formatCompactWithDecimal(-500)).toBe('-500');
|
||||
expect(formatCompactWithDecimal(-1500)).toBe('-1.5K');
|
||||
expect(formatCompactWithDecimal(-29400)).toBe('-29.4K');
|
||||
expect(formatCompactWithDecimal(-1200000)).toBe('-1.2M');
|
||||
});
|
||||
|
||||
it('should respect custom decimal places', () => {
|
||||
const { formatCompactWithDecimal } = useNumberFormatter();
|
||||
expect(formatCompactWithDecimal(1234, 2)).toBe('1.23K');
|
||||
expect(formatCompactWithDecimal(1234567, 2)).toBe('1.23M');
|
||||
});
|
||||
|
||||
it('should format with de-DE locale', () => {
|
||||
const mockLocale = ref('de-DE');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactWithDecimal } = useNumberFormatter();
|
||||
// German uses different compact notation
|
||||
const result = formatCompactWithDecimal(29400);
|
||||
expect(result).toMatch(/29[,.]?4/);
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags', () => {
|
||||
const mockLocale = ref('en_US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactWithDecimal } = useNumberFormatter();
|
||||
expect(formatCompactWithDecimal(29400)).toBe('29.4K');
|
||||
});
|
||||
|
||||
it('should handle null/undefined locale gracefully', () => {
|
||||
const mockLocale = ref(null);
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactWithDecimal } = useNumberFormatter();
|
||||
expect(formatCompactWithDecimal(29400)).toBe('29.4K');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,8 +60,37 @@ export function useNumberFormatter() {
|
||||
return new Intl.NumberFormat(resolvedLocale.value).format(num);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a number with decimal precision using compact notation (standard Intl approach)
|
||||
* - Up to 1,000: show exact number (e.g., 999)
|
||||
* - 1,000+: show compact with decimals (e.g., 29400 → "29.4K", 1500 → "1.5K")
|
||||
* - 1,000,000+: show as "X.XM" (e.g., 1,234,000 → "1.2M")
|
||||
*
|
||||
* Uses browser-native Intl.NumberFormat for proper localization
|
||||
*
|
||||
* @param {number} num - The number to format
|
||||
* @param {number} [decimals=1] - Maximum decimal places to show
|
||||
* @returns {string} Formatted number string with decimal precision
|
||||
*/
|
||||
const formatCompactWithDecimal = (num, decimals = 1) => {
|
||||
if (typeof num !== 'number' || Number.isNaN(num)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
if (Math.abs(num) < 1000) {
|
||||
return new Intl.NumberFormat(resolvedLocale.value).format(num);
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(resolvedLocale.value, {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
return {
|
||||
formatCompactNumber,
|
||||
formatFullNumber,
|
||||
formatCompactWithDecimal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('GoogleOAuthButton.vue', () => {
|
||||
const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl());
|
||||
const params = googleAuthUrl.searchParams;
|
||||
expect(googleAuthUrl.origin).toBe('https://accounts.google.com');
|
||||
expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth/oauthchooseaccount');
|
||||
expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth');
|
||||
expect(params.get('client_id')).toBe('clientId');
|
||||
expect(params.get('redirect_uri')).toBe(
|
||||
'http://localhost:3000/test-callback'
|
||||
|
||||
@@ -6,8 +6,7 @@ export default {
|
||||
// Creating the URL manually because the devise-token-auth with
|
||||
// omniauth has a standing issue on redirecting the post request
|
||||
// https://github.com/lynndylanhurley/devise_token_auth/issues/1466
|
||||
const baseUrl =
|
||||
'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount';
|
||||
const baseUrl = 'https://accounts.google.com/o/oauth2/auth';
|
||||
const clientId = window.chatwootConfig.googleOAuthClientId;
|
||||
const redirectUri = window.chatwootConfig.googleOAuthCallbackUrl;
|
||||
const responseType = 'code';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module ActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include AssigneeActivityMessageHandler
|
||||
include PriorityActivityMessageHandler
|
||||
include LabelActivityMessageHandler
|
||||
include SlaActivityMessageHandler
|
||||
@@ -104,27 +105,6 @@ module ActivityMessageHandler
|
||||
content = I18n.t("conversations.activity.#{change_type}", user_name: Current.user.name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def generate_assignee_change_activity_content(user_name)
|
||||
params = { assignee_name: assignee&.name || '', user_name: user_name }
|
||||
key = assignee_id ? 'assigned' : 'removed'
|
||||
key = 'self_assigned' if self_assign? assignee_id
|
||||
I18n.t("conversations.activity.assignee.#{key}", **params)
|
||||
end
|
||||
|
||||
def create_assignee_change_activity(user_name)
|
||||
user_name = activity_message_owner(user_name)
|
||||
|
||||
return unless user_name
|
||||
|
||||
content = generate_assignee_change_activity_content(user_name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def activity_message_owner(user_name)
|
||||
user_name = I18n.t('automation.system_name') if !user_name && Current.executed_by.present?
|
||||
user_name
|
||||
end
|
||||
end
|
||||
|
||||
ActivityMessageHandler.prepend_mod_with('ActivityMessageHandler')
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
module AssigneeActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def create_assignee_change_activity(user_name)
|
||||
user_name = activity_message_owner(user_name)
|
||||
|
||||
return unless user_name
|
||||
|
||||
content = generate_assignee_change_activity_content(user_name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def generate_assignee_change_activity_content(user_name)
|
||||
params = { assignee_name: assignee&.name || '', user_name: user_name }
|
||||
key = assignee_id ? 'assigned' : 'removed'
|
||||
key = 'self_assigned' if self_assign? assignee_id
|
||||
I18n.t("conversations.activity.assignee.#{key}", **params)
|
||||
end
|
||||
|
||||
def activity_message_owner(user_name)
|
||||
if !user_name && Current.executed_by.present?
|
||||
user_name = case Current.executed_by
|
||||
when AssignmentPolicy
|
||||
I18n.t('auto_assignment.policy_actor', policy_name: Current.executed_by.name)
|
||||
when Inbox
|
||||
I18n.t('auto_assignment.default_policy_name')
|
||||
else
|
||||
I18n.t('automation.system_name')
|
||||
end
|
||||
end
|
||||
user_name
|
||||
end
|
||||
end
|
||||
@@ -14,7 +14,7 @@ module MessageFilterHelpers
|
||||
end
|
||||
|
||||
def notifiable?
|
||||
incoming? || outgoing?
|
||||
(incoming? || outgoing?) && !private?
|
||||
end
|
||||
|
||||
def conversation_transcriptable?
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user