Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d661fb7955 | ||
|
|
7cec4ebaae | ||
|
|
6be95e79f8 | ||
|
|
3fba205993 | ||
|
|
2b85275e26 | ||
|
|
2a08e1d727 | ||
|
|
aae7212378 | ||
|
|
0966894794 | ||
|
|
85aed38cc7 | ||
|
|
a6aa2c8941 | ||
|
|
540135a1cb | ||
|
|
7666b381ae | ||
|
|
48a7388127 | ||
|
|
aeb3607ac9 | ||
|
|
5ef4e2fd88 | ||
|
|
c85598fe8b | ||
|
|
de0b78e273 |
@@ -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,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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+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')"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -72,13 +72,17 @@ class AutoAssignment::AssignmentService
|
||||
end
|
||||
|
||||
def assign_conversation(conversation, agent)
|
||||
Current.executed_by = inbox.assignment_policy || inbox
|
||||
conversation.update!(assignee: agent)
|
||||
Current.executed_by = nil
|
||||
|
||||
rate_limiter = build_rate_limiter(agent)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
|
||||
dispatch_assignment_event(conversation, agent)
|
||||
true
|
||||
ensure
|
||||
Current.executed_by = nil
|
||||
end
|
||||
|
||||
def dispatch_assignment_event(conversation, agent)
|
||||
|
||||
@@ -110,6 +110,7 @@ class CsatTemplateManagementService
|
||||
|
||||
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
|
||||
status_result = template_service.get_template_status(content_sid)
|
||||
return { template_exists: false, error: 'Template not found' } unless status_result.is_a?(Hash)
|
||||
|
||||
if status_result[:success]
|
||||
{
|
||||
@@ -130,6 +131,7 @@ class CsatTemplateManagementService
|
||||
def get_whatsapp_template_status(template)
|
||||
template_name = template['name'] || CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||
status_result = Whatsapp::CsatTemplateService.new(@inbox.channel).get_template_status(template_name)
|
||||
return { template_exists: false, error: 'Template not found' } unless status_result.is_a?(Hash)
|
||||
|
||||
if status_result[:success]
|
||||
{
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
class CsatTemplateUtilityAnalysisService
|
||||
include CsatTemplateUtilityRubric
|
||||
|
||||
pattr_initialize [:account!, :inbox!, :message!, { button_text: nil, language: 'en' }]
|
||||
|
||||
def perform
|
||||
baseline = rule_based_result
|
||||
return baseline if baseline[:classification] == 'LIKELY_MARKETING'
|
||||
|
||||
llm_result = llm_result_or_nil(baseline)
|
||||
llm_result || baseline
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def llm_result_or_nil(baseline)
|
||||
llm_output = Captain::CsatUtilityAnalysisService.new(
|
||||
account: account,
|
||||
message: message,
|
||||
button_text: button_text,
|
||||
language: language,
|
||||
baseline: baseline
|
||||
).perform
|
||||
|
||||
return nil if llm_output[:error]
|
||||
|
||||
normalize_llm_result(llm_output, baseline: baseline)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("CSAT utility LLM analysis failed for inbox #{inbox.id}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def normalize_llm_result(result, baseline:)
|
||||
classification = normalized_classification(result[:classification], baseline: baseline)
|
||||
optimized_message = result[:optimized_message].presence || baseline[:optimized_message]
|
||||
optimized_message = baseline[:optimized_message] if baseline[:classification] == 'LIKELY_MARKETING'
|
||||
|
||||
{
|
||||
classification: classification,
|
||||
optimized_message: optimized_message
|
||||
}
|
||||
end
|
||||
|
||||
def normalized_classification(value, baseline:)
|
||||
raw = value.to_s
|
||||
return 'LIKELY_MARKETING' if baseline[:classification] == 'LIKELY_MARKETING'
|
||||
|
||||
raw
|
||||
end
|
||||
|
||||
def rule_based_result
|
||||
text = sanitized_message
|
||||
marketing_hits_count = MARKETING_PATTERNS.count { |pattern| pattern.match?(text) }
|
||||
utility_hits_count = UTILITY_PATTERNS.count { |pattern| pattern.match?(text) }
|
||||
criteria = evaluate_criteria(text: text, marketing_hits_count: marketing_hits_count)
|
||||
classification = classify(criteria: criteria, utility_hits_count: utility_hits_count)
|
||||
build_rule_payload(
|
||||
classification: classification
|
||||
)
|
||||
end
|
||||
|
||||
def build_rule_payload(payload)
|
||||
{
|
||||
classification: payload[:classification],
|
||||
optimized_message: optimized_message_for(payload[:classification])
|
||||
}
|
||||
end
|
||||
|
||||
def sanitized_message
|
||||
message.to_s.squish
|
||||
end
|
||||
|
||||
def classify(criteria:, utility_hits_count:)
|
||||
return 'LIKELY_MARKETING' unless criteria[:marketing_prohibition]
|
||||
return 'LIKELY_MARKETING' unless criteria[:prohibited_content]
|
||||
return 'LIKELY_UTILITY' if criteria.values.all? && utility_hits_count >= 2
|
||||
|
||||
'UNCLEAR'
|
||||
end
|
||||
|
||||
def optimized_message_for(classification)
|
||||
return sanitized_message if classification == 'LIKELY_UTILITY'
|
||||
|
||||
build_input_aware_utility_message
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
# rubocop:disable Metrics/ModuleLength
|
||||
module CsatTemplateUtilityRubric
|
||||
LANGUAGE_FALLBACKS = {
|
||||
'en' => {
|
||||
support_request: 'support request',
|
||||
support_ticket: 'support ticket',
|
||||
support_conversation: 'support conversation',
|
||||
status_closed: 'closed',
|
||||
status_resolved: 'resolved',
|
||||
status_completed: 'completed',
|
||||
line_status: 'Your %<subject>s has been %<status>s.',
|
||||
line_help: 'If you still need help, simply reply to this message.',
|
||||
line_rate: 'To rate this support interaction, please use the button below.'
|
||||
}
|
||||
}.freeze
|
||||
|
||||
MARKETING_PATTERNS = [
|
||||
/\b(discounts?|offers?|promos?|promotions?|deals?|sales?|buy|shop|subscribe)\b/i,
|
||||
/\b(limited\s*time|don't\s*miss|exclusive|special\s*offer)\b/i,
|
||||
/\b(click\s*(here|below)\s*to\s*(buy|get|shop))\b/i,
|
||||
/\b(new\s*(plans?|products?|services?))\b/i
|
||||
].freeze
|
||||
|
||||
TRANSACTION_TRIGGER_PATTERNS = [
|
||||
/\b(closed|closing|resolved|completed)\b/i,
|
||||
/\b(ticket|request|case|conversation|support)\b/i
|
||||
].freeze
|
||||
|
||||
TRANSACTIONAL_CONTENT_PATTERNS = [
|
||||
/\b(ticket|request|case|conversation)\b/i,
|
||||
/\b(reply\s+to\s+this\s+message|if\s+you\s+still\s+need\s+help)\b/i,
|
||||
/\b(rate|califica|calificar)\b/i
|
||||
].freeze
|
||||
|
||||
PROHIBITED_CONTENT_PATTERNS = [
|
||||
/\b(contest|sweepstake|lottery|quiz)\b/i,
|
||||
/\b(password|otp|pin|cvv|credit\s*card)\b/i,
|
||||
/\b(weapon|drugs|gambling)\b/i
|
||||
].freeze
|
||||
|
||||
STATUS_PATTERNS = {
|
||||
'closed' => /\b(closed|closing)\b/i,
|
||||
'resolved' => /\b(resolved|resolve[sd]?)\b/i,
|
||||
'completed' => /\b(completed|complete[sd]?)\b/i
|
||||
}.freeze
|
||||
|
||||
SUBJECT_PATTERNS = {
|
||||
'support ticket' => /\b(ticket)\b/i,
|
||||
'support conversation' => /\b(conversation|chat)\b/i,
|
||||
'support request' => /\b(request|case|support)\b/i
|
||||
}.freeze
|
||||
|
||||
UTILITY_PATTERNS = [
|
||||
/\b(support|ticket|request|conversation|case)\b/i,
|
||||
/\b(closed|resolved|completed)\b/i,
|
||||
/\b(reply\s+to\s+this\s+message\b)/i,
|
||||
/\b(if\s+you\s+still\s+need\s+help)\b/i,
|
||||
/\b(rate\s+this\s+(support|interaction|conversation))\b/i
|
||||
].freeze
|
||||
|
||||
private
|
||||
|
||||
def build_input_aware_utility_message
|
||||
text = translation_pack
|
||||
subject = detected_subject
|
||||
status = detected_status
|
||||
intro = extracted_intro_sentence
|
||||
|
||||
parts = []
|
||||
parts << intro if intro.present?
|
||||
parts << format(text[:line_status], subject: subject, status: status)
|
||||
parts << text[:line_help]
|
||||
parts << text[:line_rate]
|
||||
parts.join(' ')
|
||||
end
|
||||
|
||||
def detected_status
|
||||
matched = STATUS_PATTERNS.find { |_key, pattern| pattern.match?(sanitized_message) }
|
||||
status_key = matched&.first || 'closed'
|
||||
translation_pack[:"status_#{status_key}"]
|
||||
end
|
||||
|
||||
def detected_subject
|
||||
matched = SUBJECT_PATTERNS.find { |_key, pattern| pattern.match?(sanitized_message) }
|
||||
subject_key = matched&.first&.tr(' ', '_') || 'support_request'
|
||||
translation_pack[subject_key.to_sym]
|
||||
end
|
||||
|
||||
def extracted_intro_sentence
|
||||
first_sentence = sanitized_message.split(/(?<=[.!?])\s+/).first.to_s
|
||||
return nil if first_sentence.blank?
|
||||
return nil if MARKETING_PATTERNS.any? { |pattern| pattern.match?(first_sentence) }
|
||||
return nil unless first_sentence.match?(/\b(thanks|thank you|hello|hi)\b/i)
|
||||
|
||||
normalized = first_sentence.gsub(/\s+/, ' ').strip
|
||||
normalized.ends_with?('.', '!', '?') ? normalized : "#{normalized}."
|
||||
end
|
||||
|
||||
def translation_pack
|
||||
LANGUAGE_FALLBACKS.fetch(primary_language_code, LANGUAGE_FALLBACKS['en'])
|
||||
end
|
||||
|
||||
def primary_language_code
|
||||
language.to_s.downcase.split(/[-_]/).first
|
||||
end
|
||||
|
||||
def evaluate_criteria(text:, marketing_hits_count:)
|
||||
{
|
||||
trigger: TRANSACTION_TRIGGER_PATTERNS.any? { |pattern| pattern.match?(text) },
|
||||
transactional_content: TRANSACTIONAL_CONTENT_PATTERNS.count { |pattern| pattern.match?(text) } >= 2,
|
||||
marketing_prohibition: marketing_hits_count.zero?,
|
||||
prohibited_content: PROHIBITED_CONTENT_PATTERNS.none? { |pattern| pattern.match?(text) },
|
||||
clarity_and_utility: clear_utility_intent?(text)
|
||||
}
|
||||
end
|
||||
|
||||
def clear_utility_intent?(text)
|
||||
has_support_context = text.match?(/\b(support|ticket|request|case|conversation)\b/i)
|
||||
has_actionable_next_step = text.match?(/\b(reply\s+to\s+this\s+message)\b/i) ||
|
||||
text.match?(/\b(if\s+you\s+still\s+need\s+help)\b/i) ||
|
||||
text.match?(/\b(rate\s+this\s+(support|interaction|conversation))\b/i)
|
||||
has_support_context && has_actionable_next_step
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ModuleLength
|
||||
@@ -2,7 +2,7 @@ class Whatsapp::CsatTemplateService
|
||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||
DEFAULT_LANGUAGE = 'en'.freeze
|
||||
WHATSAPP_API_VERSION = 'v14.0'.freeze
|
||||
TEMPLATE_CATEGORY = 'MARKETING'.freeze
|
||||
TEMPLATE_CATEGORY = 'UTILITY'.freeze
|
||||
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
|
||||
|
||||
def initialize(whatsapp_channel)
|
||||
|
||||
@@ -438,6 +438,9 @@ en:
|
||||
seconds:
|
||||
one: '%{count} second'
|
||||
other: '%{count} seconds'
|
||||
auto_assignment:
|
||||
default_policy_name: 'Default Policy'
|
||||
policy_actor: 'Automation System via %{policy_name}'
|
||||
automation:
|
||||
system_name: 'Automation System'
|
||||
crm:
|
||||
|
||||
+3
-1
@@ -224,7 +224,9 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
|
||||
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
|
||||
post :analyze, on: :collection
|
||||
end
|
||||
end
|
||||
|
||||
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
||||
|
||||
@@ -2,7 +2,6 @@ module Enterprise::Account
|
||||
# TODO: Remove this when we upgrade administrate gem to the latest version
|
||||
# this is a temporary method since current administrate doesn't support virtual attributes
|
||||
def manually_managed_features; end
|
||||
def manually_managed_features=(_value); end
|
||||
|
||||
# Auto-sync advanced_assignment with assignment_v2 when features are bulk-updated via admin UI
|
||||
def selected_feature_flags=(features)
|
||||
|
||||
@@ -3,6 +3,8 @@ require 'agents/instrumentation'
|
||||
|
||||
class Captain::Assistant::AgentRunnerService
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
include Captain::Assistant::RunnerCallbacksHelper
|
||||
include Captain::Assistant::TracePayloadHelper
|
||||
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
@@ -21,13 +23,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
end
|
||||
|
||||
def generate_response(message_history: [])
|
||||
agents = build_and_wire_agents
|
||||
context = build_context(message_history)
|
||||
message_to_process = extract_last_user_message(message_history)
|
||||
runner = Agents::Runner.with_agents(*agents)
|
||||
runner = add_usage_metadata_callback(runner)
|
||||
runner = add_callbacks_to_runner(runner) if @callbacks.any?
|
||||
install_instrumentation(runner)
|
||||
message_to_process, context = run_payload(message_history)
|
||||
result = runner.run(message_to_process, context: context, max_turns: 100)
|
||||
|
||||
process_agent_result(result)
|
||||
@@ -45,7 +41,10 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
def build_context(message_history)
|
||||
conversation_history = message_history.map do |msg|
|
||||
content = extract_text_from_content(msg[:content])
|
||||
content = msg[:content]
|
||||
# Preserve multimodal arrays (with image_url entries) as-is for the runner to restore with attachments.
|
||||
# Only extract text from non-array formats (hashes from agent structured output, plain strings).
|
||||
content = extract_text_from_content(content) unless content.is_a?(Array)
|
||||
|
||||
{
|
||||
role: msg[:role].to_sym,
|
||||
@@ -63,8 +62,22 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
def extract_last_user_message(message_history)
|
||||
last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' }
|
||||
return '' if last_user_msg.blank?
|
||||
|
||||
extract_text_from_content(last_user_msg[:content])
|
||||
content = last_user_msg[:content]
|
||||
return extract_text_from_content(content) unless content.is_a?(Array)
|
||||
|
||||
text, attachments = Captain::OpenAiMessageBuilderService.extract_text_and_attachments(content)
|
||||
return text if attachments.blank?
|
||||
|
||||
RubyLLM::Content.new(text, attachments)
|
||||
end
|
||||
|
||||
def message_history_without_last_user_message(message_history)
|
||||
last_user_index = message_history.rindex { |msg| msg[:role] == 'user' }
|
||||
return message_history if last_user_index.nil?
|
||||
|
||||
message_history.reject.with_index { |_msg, index| index == last_user_index }
|
||||
end
|
||||
|
||||
def extract_text_from_content(content)
|
||||
@@ -143,28 +156,25 @@ class Captain::Assistant::AgentRunnerService
|
||||
},
|
||||
attribute_provider: ->(context_wrapper) { dynamic_trace_attributes(context_wrapper) }
|
||||
)
|
||||
register_trace_input_callback(runner)
|
||||
end
|
||||
|
||||
def dynamic_trace_attributes(context_wrapper)
|
||||
state = context_wrapper&.context&.dig(:state) || {}
|
||||
conversation = state[:conversation] || {}
|
||||
trace_input = context_wrapper&.context&.dig(:captain_v2_trace_input)
|
||||
|
||||
{
|
||||
ATTR_LANGFUSE_USER_ID => state[:account_id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'assistant_id') => state[:assistant_id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type]
|
||||
format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type],
|
||||
ATTR_LANGFUSE_TRACE_INPUT => trace_input,
|
||||
ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input
|
||||
}.compact.transform_values(&:to_s)
|
||||
end
|
||||
|
||||
def add_callbacks_to_runner(runner)
|
||||
runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
|
||||
runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
|
||||
runner = add_tool_complete_callback(runner) if @callbacks[:on_tool_complete]
|
||||
runner = add_agent_handoff_callback(runner) if @callbacks[:on_agent_handoff]
|
||||
runner
|
||||
end
|
||||
|
||||
def add_usage_metadata_callback(runner)
|
||||
return runner unless ChatwootApp.otel_enabled?
|
||||
|
||||
@@ -195,35 +205,20 @@ class Captain::Assistant::AgentRunnerService
|
||||
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credit_used'), credit_used.to_s)
|
||||
end
|
||||
|
||||
def add_agent_thinking_callback(runner)
|
||||
runner.on_agent_thinking do |*args|
|
||||
@callbacks[:on_agent_thinking].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for agent_thinking: #{e.message}"
|
||||
def runner
|
||||
@runner ||= begin
|
||||
configured_runner = Agents::Runner.with_agents(*build_and_wire_agents)
|
||||
configured_runner = add_usage_metadata_callback(configured_runner)
|
||||
configured_runner = add_callbacks_to_runner(configured_runner) if @callbacks.any?
|
||||
install_instrumentation(configured_runner)
|
||||
configured_runner
|
||||
end
|
||||
end
|
||||
|
||||
def add_tool_start_callback(runner)
|
||||
runner.on_tool_start do |*args|
|
||||
@callbacks[:on_tool_start].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for tool_start: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def add_tool_complete_callback(runner)
|
||||
runner.on_tool_complete do |*args|
|
||||
@callbacks[:on_tool_complete].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for tool_complete: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def add_agent_handoff_callback(runner)
|
||||
runner.on_agent_handoff do |*args|
|
||||
@callbacks[:on_agent_handoff].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for agent_handoff: #{e.message}"
|
||||
end
|
||||
def run_payload(message_history)
|
||||
message_to_process = extract_last_user_message(message_history)
|
||||
context = build_context(message_history_without_last_user_message(message_history))
|
||||
enrich_context_with_trace_payload!(context, message_history, message_to_process)
|
||||
[message_to_process, context]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
module Captain::Assistant::RunnerCallbacksHelper
|
||||
private
|
||||
|
||||
def add_callbacks_to_runner(runner)
|
||||
runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
|
||||
runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
|
||||
runner = add_tool_complete_callback(runner) if @callbacks[:on_tool_complete]
|
||||
runner = add_agent_handoff_callback(runner) if @callbacks[:on_agent_handoff]
|
||||
runner
|
||||
end
|
||||
|
||||
def register_trace_input_callback(runner)
|
||||
runner.on_agent_thinking do |_agent_name, _input, context_wrapper|
|
||||
tracing = context_wrapper&.context&.dig(:__otel_tracing)
|
||||
next unless tracing
|
||||
|
||||
trace_input = context_wrapper.context[:captain_v2_trace_current_input]
|
||||
tracing[:pending_llm_input] = trace_input if trace_input.present?
|
||||
end
|
||||
end
|
||||
|
||||
def add_agent_thinking_callback(runner)
|
||||
runner.on_agent_thinking do |*args|
|
||||
@callbacks[:on_agent_thinking].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for agent_thinking: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def add_tool_start_callback(runner)
|
||||
runner.on_tool_start do |*args|
|
||||
@callbacks[:on_tool_start].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for tool_start: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def add_tool_complete_callback(runner)
|
||||
runner.on_tool_complete do |*args|
|
||||
@callbacks[:on_tool_complete].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for tool_complete: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def add_agent_handoff_callback(runner)
|
||||
runner.on_agent_handoff do |*args|
|
||||
@callbacks[:on_agent_handoff].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for agent_handoff: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
module Captain::Assistant::TracePayloadHelper
|
||||
private
|
||||
|
||||
def enrich_context_with_trace_payload!(context, message_history, message_to_process)
|
||||
context[:captain_v2_trace_input] = serialize_trace_messages(message_history)
|
||||
context[:captain_v2_trace_current_input] = serialize_trace_content(message_to_process)
|
||||
end
|
||||
|
||||
def serialize_trace_messages(message_history)
|
||||
message_history.map do |message|
|
||||
{
|
||||
role: message[:role].to_s,
|
||||
content: trace_content_payload(message[:content])
|
||||
}
|
||||
end.to_json
|
||||
end
|
||||
|
||||
def serialize_trace_content(content)
|
||||
payload = trace_content_payload(content)
|
||||
return '' if payload.blank?
|
||||
|
||||
payload.is_a?(String) ? payload : payload.to_json
|
||||
end
|
||||
|
||||
def trace_content_payload(content)
|
||||
case content
|
||||
when RubyLLM::Content
|
||||
trace_parts_from_ruby_llm_content(content)
|
||||
when Array, Hash
|
||||
content
|
||||
when NilClass
|
||||
''
|
||||
else
|
||||
content.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def trace_parts_from_ruby_llm_content(content)
|
||||
parts = []
|
||||
parts << { type: 'text', text: content.text } if content.text.present?
|
||||
|
||||
content.attachments.each do |attachment|
|
||||
parts << { type: 'image_url', image_url: { url: attachment.source.to_s } }
|
||||
end
|
||||
|
||||
return '' if parts.blank?
|
||||
return parts.first[:text] if parts.one? && parts.first[:type] == 'text'
|
||||
|
||||
parts
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
class Captain::CsatUtilityAnalysisService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :message!, { button_text: nil, language: 'en', baseline: {} }]
|
||||
|
||||
def perform
|
||||
api_response = make_api_call(
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: message }
|
||||
]
|
||||
)
|
||||
|
||||
return api_response if api_response[:error]
|
||||
|
||||
build_result(api_response[:message])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_result(response_message)
|
||||
parsed = parse_json_response(response_message)
|
||||
return { error: 'Invalid LLM response format' } if parsed.blank?
|
||||
|
||||
core_result(parsed).merge(message: response_message)
|
||||
end
|
||||
|
||||
def core_result(parsed)
|
||||
{
|
||||
classification: normalize_classification(parsed['classification']),
|
||||
optimized_message: parsed['optimized_message'].presence || baseline[:optimized_message]
|
||||
}
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
template = prompt_from_file('csat_utility_analysis')
|
||||
Liquid::Template.parse(template).render(prompt_variables)
|
||||
end
|
||||
|
||||
def prompt_variables
|
||||
{
|
||||
'message' => message.to_s,
|
||||
'button_text' => button_text.to_s,
|
||||
'language' => language.to_s,
|
||||
'baseline_classification' => baseline[:classification].to_s
|
||||
}
|
||||
end
|
||||
|
||||
def parse_json_response(content)
|
||||
raw = content.to_s.strip
|
||||
json = raw.match(/```json\s*(.*?)\s*```/m)&.captures&.first || raw
|
||||
JSON.parse(json)
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def normalize_classification(value)
|
||||
normalized = value.to_s.upcase
|
||||
return normalized if %w[LIKELY_UTILITY LIKELY_MARKETING UNCLEAR].include?(normalized)
|
||||
|
||||
baseline[:classification].presence || 'UNCLEAR'
|
||||
end
|
||||
|
||||
def event_name
|
||||
'csat_utility_analysis'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
You are a WhatsApp template compliance assistant.
|
||||
Your task is to evaluate whether a CSAT template message is likely to be approved as UTILITY vs MARKETING under Meta policy.
|
||||
|
||||
Rules:
|
||||
1. Prefer UTILITY only when the message is tied to an existing support or transactional event.
|
||||
2. Avoid promotional language, upsell, cross-sell, offers, discounts, or purchase intent.
|
||||
3. Keep the rewritten message concise, explicit, and purely transactional.
|
||||
4. Do not invent product offers or marketing phrases.
|
||||
|
||||
Input:
|
||||
- Message: {{ message }}
|
||||
- Button text: {{ button_text }}
|
||||
- Language code: {{ language }}
|
||||
|
||||
Baseline heuristic:
|
||||
- Classification: {{ baseline_classification }}
|
||||
|
||||
Return ONLY valid JSON with this shape (example):
|
||||
{
|
||||
"classification": "LIKELY_UTILITY",
|
||||
"optimized_message": "rewritten utility-safe message"
|
||||
}
|
||||
|
||||
Allowed values for "classification": "LIKELY_UTILITY", "LIKELY_MARKETING", or "UNCLEAR".
|
||||
|
||||
Important:
|
||||
- Write `optimized_message` in the same language as `Language code`.
|
||||
@@ -1,223 +0,0 @@
|
||||
# Move Inbox Rake Task
|
||||
#
|
||||
# Interactively moves a single inbox (and all associated records) from one
|
||||
# Chatwoot account to another using Inboxes::MoveInboxToAccountService.
|
||||
#
|
||||
# Usage:
|
||||
# bundle exec rake inbox:move
|
||||
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
# rubocop:disable Metrics/ModuleLength
|
||||
# rubocop:disable Style/FormatStringToken
|
||||
module MoveInboxTasks
|
||||
def self.prompt(message)
|
||||
print "#{message}: "
|
||||
$stdin.gets.chomp
|
||||
end
|
||||
|
||||
def self.run
|
||||
puts 'Move Inbox Between Accounts'
|
||||
puts '=' * 40
|
||||
|
||||
source_account = collect_source_account
|
||||
inbox = collect_inbox(source_account)
|
||||
destination_account = collect_destination_account(source_account)
|
||||
|
||||
puts "\nRunning dry-run..."
|
||||
result = dry_run(source_account, inbox, destination_account)
|
||||
|
||||
if result[:validation_errors].present?
|
||||
puts "\nValidation errors:"
|
||||
result[:validation_errors].each { |e| puts " - #{e}" }
|
||||
abort "\nAborted due to validation errors."
|
||||
end
|
||||
|
||||
print_preview(result)
|
||||
|
||||
confirmation = prompt("\nProceed with migration? (y/N)")
|
||||
abort 'Migration cancelled.' unless confirmation.downcase == 'y'
|
||||
|
||||
puts "\nExecuting migration..."
|
||||
logger = Logger.new($stdout)
|
||||
logger.formatter = proc { |_severity, _time, _progname, msg| " #{msg}\n" }
|
||||
|
||||
result = execute(source_account, inbox, destination_account, logger)
|
||||
|
||||
if result[:status] == :failed
|
||||
puts "\nMigration FAILED (all changes rolled back)"
|
||||
puts " Error: #{result[:error][:class]}: #{result[:error][:message]}"
|
||||
else
|
||||
puts "\nMigration complete!"
|
||||
end
|
||||
print_result(result)
|
||||
end
|
||||
|
||||
def self.collect_source_account
|
||||
account_id = prompt("\nSource account ID")
|
||||
abort 'Error: Account ID is required' if account_id.blank?
|
||||
|
||||
account = Account.find_by(id: account_id)
|
||||
abort "Error: Account ##{account_id} not found" unless account
|
||||
|
||||
puts " Account: #{account.name} (##{account.id})"
|
||||
account
|
||||
end
|
||||
|
||||
def self.collect_inbox(account)
|
||||
inboxes = account.inboxes.includes(:channel).order(:id)
|
||||
|
||||
abort "Error: Account ##{account.id} has no inboxes" if inboxes.empty?
|
||||
|
||||
puts "\nInboxes in account ##{account.id}:"
|
||||
puts ' ID Name Channel Type Created'
|
||||
puts " #{'-' * 80}"
|
||||
inboxes.each do |inbox|
|
||||
puts format(' %-6d %-30s %-25s %s', inbox.id, inbox.name.truncate(28), inbox.channel_type, inbox.created_at.to_date)
|
||||
end
|
||||
|
||||
inbox_id = prompt("\nInbox ID to move")
|
||||
abort 'Error: Inbox ID is required' if inbox_id.blank?
|
||||
|
||||
inbox = inboxes.find { |i| i.id == inbox_id.to_i }
|
||||
abort "Error: Inbox ##{inbox_id} not found in account ##{account.id}" unless inbox
|
||||
|
||||
puts " Selected: #{inbox.name} (#{inbox.channel_type})"
|
||||
inbox
|
||||
end
|
||||
|
||||
def self.collect_destination_account(source_account)
|
||||
account_id = prompt("\nDestination account ID")
|
||||
abort 'Error: Account ID is required' if account_id.blank?
|
||||
|
||||
abort 'Error: Destination must be different from source account' if account_id.to_i == source_account.id
|
||||
|
||||
account = Account.find_by(id: account_id)
|
||||
abort "Error: Account ##{account_id} not found" unless account
|
||||
|
||||
puts " Account: #{account.name} (##{account.id})"
|
||||
account
|
||||
end
|
||||
|
||||
def self.dry_run(source_account, inbox, destination_account)
|
||||
require Rails.root.join('script/move_inbox')
|
||||
|
||||
Inboxes::MoveInboxToAccountService.call(
|
||||
source_account_id: source_account.id,
|
||||
source_inbox_id: inbox.id,
|
||||
destination_account_id: destination_account.id,
|
||||
dry_run: true
|
||||
)
|
||||
end
|
||||
|
||||
def self.execute(source_account, inbox, destination_account, logger)
|
||||
Inboxes::MoveInboxToAccountService.call(
|
||||
source_account_id: source_account.id,
|
||||
source_inbox_id: inbox.id,
|
||||
destination_account_id: destination_account.id,
|
||||
dry_run: false,
|
||||
logger: logger
|
||||
)
|
||||
end
|
||||
|
||||
def self.print_preview(result)
|
||||
will = result[:will]
|
||||
custom = result[:custom_attribute_definitions]
|
||||
|
||||
puts "\nDry-run summary"
|
||||
puts '-' * 40
|
||||
|
||||
inbox_info = will[:inbox] || {}
|
||||
puts "Inbox: #{inbox_info[:source_name]} (#{inbox_info[:channel_type]})"
|
||||
puts "Destination: #{inbox_info[:destination_account_name]}"
|
||||
|
||||
puts "\nRecords to copy/migrate:"
|
||||
puts format(' %-30s %s', 'Labels', will[:labels])
|
||||
puts format(' %-30s %s', 'Account users', will[:account_users])
|
||||
puts format(' %-30s %s', 'Conversations', will[:conversations])
|
||||
puts format(' %-30s %s', 'Contacts', will[:contacts])
|
||||
print_contacts_to_duplicate(will[:contacts_to_duplicate])
|
||||
puts format(' %-30s %s', 'Contact inboxes', will[:contact_inboxes])
|
||||
puts format(' %-30s %s', 'Messages', will[:messages])
|
||||
puts format(' %-30s %s', 'Attachments', will[:attachments])
|
||||
puts format(' %-30s %s', 'Notes', will[:notes])
|
||||
puts format(' %-30s %s', 'CSAT responses', will[:csat_survey_responses])
|
||||
puts format(' %-30s %s', 'Mentions', will[:mentions])
|
||||
puts format(' %-30s %s', 'Reporting events', will[:reporting_events])
|
||||
puts format(' %-30s %s', 'Conversation participants', will[:conversation_participants])
|
||||
puts format(' %-30s %s', 'Inbox members', will[:inbox_members])
|
||||
|
||||
puts "\nRecords to clean up (delete):"
|
||||
puts format(' %-30s %s', 'Notifications', will[:notifications])
|
||||
puts format(' %-30s %s', 'Applied SLAs', will[:applied_slas])
|
||||
puts format(' %-30s %s', 'SLA events', will[:sla_events])
|
||||
puts format(' %-30s %s', 'Agent bot inboxes', will[:agent_bot_inboxes])
|
||||
puts format(' %-30s %s', 'Inbox webhooks', will[:inbox_webhooks])
|
||||
puts format(' %-30s %s', 'Inbox hooks', will[:inbox_hooks])
|
||||
puts format(' %-30s %s', 'Campaigns', will[:campaigns])
|
||||
|
||||
return if custom[:source_total].zero?
|
||||
|
||||
puts "\nCustom attribute definitions:"
|
||||
puts format(' %-30s %s', 'Source total', custom[:source_total])
|
||||
puts format(' %-30s %s', 'To create', custom[:to_create])
|
||||
puts format(' %-30s %s', 'Skipped (existing)', custom[:skipped_existing])
|
||||
puts format(' %-30s %s', 'Skipped (reserved)', custom[:skipped_reserved])
|
||||
end
|
||||
|
||||
def self.print_contacts_to_duplicate(contacts)
|
||||
return if contacts.blank?
|
||||
|
||||
puts "\n Contacts with conversations in other inboxes (will be duplicated):"
|
||||
puts ' ID Name Email Phone'
|
||||
puts " #{'-' * 75}"
|
||||
contacts.each do |c|
|
||||
puts format(' %-8s %-25s %-30s %s', c[:id], c[:name].to_s.truncate(23), c[:email].to_s.truncate(28), c[:phone_number])
|
||||
end
|
||||
puts ''
|
||||
end
|
||||
|
||||
def self.print_result(result)
|
||||
moved = result[:moved]
|
||||
|
||||
puts "\nFinal results (status: #{result[:status]})"
|
||||
puts '-' * 40
|
||||
|
||||
puts 'Records migrated:'
|
||||
puts format(' %-30s %s', 'Labels copied', moved[:labels_copied])
|
||||
puts format(' %-30s %s', 'Account users copied', moved[:account_users_copied])
|
||||
puts format(' %-30s %s', 'Conversations', moved[:conversations])
|
||||
puts format(' %-30s %s', 'Contacts', moved[:contacts])
|
||||
puts format(' %-30s %s', 'Contacts duplicated', moved[:contacts_duplicated])
|
||||
puts format(' %-30s %s', 'Contact inboxes', moved[:contact_inboxes])
|
||||
puts format(' %-30s %s', 'Messages', moved[:messages])
|
||||
puts format(' %-30s %s', 'Attachments', moved[:attachments])
|
||||
puts format(' %-30s %s', 'Notes', moved[:notes])
|
||||
puts format(' %-30s %s', 'CSAT responses', moved[:csat_survey_responses])
|
||||
puts format(' %-30s %s', 'Mentions', moved[:mentions])
|
||||
puts format(' %-30s %s', 'Reporting events', moved[:reporting_events])
|
||||
puts format(' %-30s %s', 'Conversation participants', moved[:conversation_participants])
|
||||
puts format(' %-30s %s', 'Inbox members', moved[:inbox_members])
|
||||
|
||||
puts "\nRecords cleaned up:"
|
||||
puts format(' %-30s %s', 'Notifications deleted', moved[:notifications_deleted])
|
||||
puts format(' %-30s %s', 'Applied SLAs deleted', moved[:applied_slas_deleted])
|
||||
puts format(' %-30s %s', 'SLA events deleted', moved[:sla_events_deleted])
|
||||
puts format(' %-30s %s', 'Agent bot inboxes deleted', moved[:agent_bot_inboxes_deleted])
|
||||
puts format(' %-30s %s', 'Inbox webhooks deleted', moved[:inbox_webhooks_deleted])
|
||||
puts format(' %-30s %s', 'Inbox hooks deleted', moved[:inbox_hooks_deleted])
|
||||
puts format(' %-30s %s', 'Campaigns deleted', moved[:campaigns_deleted])
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
# rubocop:enable Metrics/ModuleLength
|
||||
# rubocop:enable Style/FormatStringToken
|
||||
|
||||
namespace :inbox do
|
||||
desc 'Interactively move an inbox (and all associated records) between accounts'
|
||||
task move: :environment do
|
||||
require Rails.root.join('script/move_inbox')
|
||||
MoveInboxTasks.run
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -95,6 +95,7 @@
|
||||
"video.js": "7.18.1",
|
||||
"videojs-record": "4.5.0",
|
||||
"videojs-wavesurfer": "3.8.0",
|
||||
"virtua": "^0.48.6",
|
||||
"vue": "^3.5.12",
|
||||
"vue-chartjs": "5.3.1",
|
||||
"vue-datepicker-next": "^1.0.3",
|
||||
@@ -103,7 +104,6 @@
|
||||
"vue-letter": "^0.2.1",
|
||||
"vue-router": "~4.4.5",
|
||||
"vue-upload-component": "^3.1.17",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8",
|
||||
"vue3-click-away": "^1.2.4",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuex": "~4.1.0",
|
||||
|
||||
Generated
+27
-29
@@ -205,6 +205,9 @@ importers:
|
||||
videojs-wavesurfer:
|
||||
specifier: 3.8.0
|
||||
version: 3.8.0
|
||||
virtua:
|
||||
specifier: ^0.48.6
|
||||
version: 0.48.6(vue@3.5.12(typescript@5.6.2))
|
||||
vue:
|
||||
specifier: ^3.5.12
|
||||
version: 3.5.12(typescript@5.6.2)
|
||||
@@ -229,9 +232,6 @@ importers:
|
||||
vue-upload-component:
|
||||
specifier: ^3.1.17
|
||||
version: 3.1.17
|
||||
vue-virtual-scroller:
|
||||
specifier: ^2.0.0-beta.8
|
||||
version: 2.0.0-beta.8(vue@3.5.12(typescript@5.6.2))
|
||||
vue3-click-away:
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4
|
||||
@@ -3305,9 +3305,6 @@ packages:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
mitt@2.1.0:
|
||||
resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
@@ -4511,6 +4508,26 @@ packages:
|
||||
videojs-wavesurfer@3.8.0:
|
||||
resolution: {integrity: sha512-qHucCBiEW+4dZ0Zp1k4R1elprUOV+QDw87UDA9QRXtO7GK/MrSdoe/TMFxP9SLnJCiX9xnYdf4OQgrmvJ9UVVw==}
|
||||
|
||||
virtua@0.48.6:
|
||||
resolution: {integrity: sha512-Cl4uMvMV5c9RuOy9zhkFMYwx/V4YLBMYLRSWkO8J46opQZ3P7KMq0CqCVOOAKUckjl/r//D2jWTBGYWzmgtzrQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.14.0'
|
||||
react-dom: '>=16.14.0'
|
||||
solid-js: '>=1.0'
|
||||
svelte: '>=5.0'
|
||||
vue: '>=3.2'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
solid-js:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
vite-node@2.0.1:
|
||||
resolution: {integrity: sha512-nVd6kyhPAql0s+xIVJzuF+RSRH8ZimNrm6U8ZvTA4MXv8CHI17TFaQwRaFiK75YX6XeFqZD4IoAaAfi9OR1XvQ==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -4625,11 +4642,6 @@ packages:
|
||||
vue-letter@0.2.1:
|
||||
resolution: {integrity: sha512-IYWp47XUikjKfEniWYlFxeJFKABZwAE5IEjz866qCBytBr2dzqVDdjoMDpBP//krxkzN/QZYyHe6C09y/IODYg==}
|
||||
|
||||
vue-observe-visibility@2.0.0-alpha.1:
|
||||
resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
vue-resize@2.0.0-alpha.1:
|
||||
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
|
||||
peerDependencies:
|
||||
@@ -4643,11 +4655,6 @@ packages:
|
||||
vue-upload-component@3.1.17:
|
||||
resolution: {integrity: sha512-1orTC5apoFzBz4ku2HAydpviaAOck+ABc83rGypIK/Bgl+TqhtoWsQOhXqbb7vDv7pKlvRVWwml9PM224HyhkA==}
|
||||
|
||||
vue-virtual-scroller@2.0.0-beta.8:
|
||||
resolution: {integrity: sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.2.0
|
||||
|
||||
vue3-click-away@1.2.4:
|
||||
resolution: {integrity: sha512-O9Z2KlvIhJT8OxaFy04eiZE9rc1Mk/bp+70dLok68ko3Kr8AW5dU+j8avSk4GDQu94FllSr4m5ul4BpzlKOw1A==}
|
||||
|
||||
@@ -8226,8 +8233,6 @@ snapshots:
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
mitt@2.1.0: {}
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mlly@1.8.0:
|
||||
@@ -9574,6 +9579,10 @@ snapshots:
|
||||
video.js: 7.18.1
|
||||
wavesurfer.js: 7.8.6
|
||||
|
||||
virtua@0.48.6(vue@3.5.12(typescript@5.6.2)):
|
||||
optionalDependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vite-node@2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
@@ -9692,10 +9701,6 @@ snapshots:
|
||||
dependencies:
|
||||
lettersanitizer: 1.0.6
|
||||
|
||||
vue-observe-visibility@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-resize@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
@@ -9707,13 +9712,6 @@ snapshots:
|
||||
|
||||
vue-upload-component@3.1.17: {}
|
||||
|
||||
vue-virtual-scroller@2.0.0-beta.8(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
mitt: 2.1.0
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
vue-observe-visibility: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2))
|
||||
vue-resize: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2))
|
||||
|
||||
vue3-click-away@1.2.4: {}
|
||||
|
||||
vue@3.5.12(typescript@5.6.2):
|
||||
|
||||
@@ -1,668 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Moves a single inbox (any channel type) and all records attached to that inbox
|
||||
# from one Chatwoot account to another.
|
||||
#
|
||||
# What is migrated:
|
||||
# - Source inbox record and channel config
|
||||
# - Conversations in the inbox, along with their contact + contact_inbox mapping
|
||||
# - Dependent records (messages, attachments, notes, csat responses, mentions, events)
|
||||
# - Custom attribute definitions are copied only when missing in destination
|
||||
#
|
||||
# What is cleaned up (deleted):
|
||||
# - Notification rows for the migrated conversations
|
||||
# - ConversationParticipant rows (reference source-account users)
|
||||
# - AppliedSla / SlaEvent rows (enterprise, account-scoped)
|
||||
# - InboxMember rows (reference source-account agents)
|
||||
# - AgentBotInbox rows
|
||||
# - Inbox-scoped webhooks and integration hooks
|
||||
# - Campaigns associated with the inbox
|
||||
#
|
||||
# Validation is strict:
|
||||
# - Destination account must be empty for operational data (excluding agents/custom attrs)
|
||||
# - All source contacts participating in the moved inbox must not have conversations
|
||||
# in any other inbox
|
||||
# - Destination must be different from source
|
||||
#
|
||||
# Caveats:
|
||||
# - Contacts are re-owned by destination account (existing contact records are moved,
|
||||
# not recreated).
|
||||
# - Account users (agents/admins) from the source account are copied into the
|
||||
# destination account so that assignee_id is retained on conversations.
|
||||
# - Cross-references that are account-specific are cleared on conversation rows:
|
||||
# team_id, assignee_agent_bot_id, campaign_id, sla_policy_id, cached_label_list.
|
||||
# - SLA policies / enterprise SLA rows are not migrated.
|
||||
# - Custom attribute definitions are copied only when missing in destination and
|
||||
# are skipped if they collide with standard attributes.
|
||||
|
||||
# rubocop:disable Metrics/ClassLength
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
class Inboxes::MoveInboxToAccountService
|
||||
CUSTOM_ATTRIBUTE_MODELS = %w[
|
||||
conversation_attribute
|
||||
contact_attribute
|
||||
].freeze
|
||||
ERROR_PREFIX = 'Inbox migration validation failed'
|
||||
RESERVED_CUSTOM_ATTRIBUTE_KEYS = {
|
||||
conversation_attribute: CustomAttributeDefinition::STANDARD_ATTRIBUTES[:conversation],
|
||||
contact_attribute: CustomAttributeDefinition::STANDARD_ATTRIBUTES[:contact]
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
# rubocop:disable Metrics/ParameterLists
|
||||
def call(source_account_id:, source_inbox_id:, destination_account_id:, dry_run: true, batch_size: 100, logger: Logger.new(File::NULL))
|
||||
new(
|
||||
source_account_id: source_account_id,
|
||||
source_inbox_id: source_inbox_id,
|
||||
destination_account_id: destination_account_id,
|
||||
dry_run: dry_run,
|
||||
batch_size: batch_size,
|
||||
logger: logger
|
||||
).perform
|
||||
end
|
||||
# rubocop:enable Metrics/ParameterLists
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/ParameterLists
|
||||
def initialize(source_account_id:, source_inbox_id:, destination_account_id:, dry_run: true, batch_size: 100, logger: Logger.new(File::NULL))
|
||||
@source_account_id = source_account_id
|
||||
@source_inbox_id = source_inbox_id
|
||||
@destination_account_id = destination_account_id
|
||||
@dry_run = dry_run
|
||||
@batch_size = batch_size
|
||||
@logger = logger
|
||||
@summary = nil
|
||||
@prepared = false
|
||||
@source_to_destination_contact = {}
|
||||
@destination_contact_inboxes_by_source_id = {}
|
||||
end
|
||||
# rubocop:enable Metrics/ParameterLists
|
||||
|
||||
def perform
|
||||
run(raise_on_validation: false)
|
||||
end
|
||||
|
||||
def perform!
|
||||
run(raise_on_validation: true)
|
||||
end
|
||||
|
||||
def summary
|
||||
prepare_summary
|
||||
@summary
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :dry_run
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def run(raise_on_validation: false)
|
||||
prepare_summary
|
||||
|
||||
if dry_run
|
||||
@summary[:status] = :dry_run
|
||||
return @summary
|
||||
end
|
||||
|
||||
if @summary[:validation_errors].present?
|
||||
message = "#{ERROR_PREFIX}: #{@summary[:validation_errors].join(', ')}"
|
||||
return @summary if raise_on_validation == false
|
||||
|
||||
raise ArgumentError, message
|
||||
end
|
||||
|
||||
execute_migration!
|
||||
@summary[:status] = :applied
|
||||
@summary
|
||||
rescue StandardError => e
|
||||
@summary[:status] = :failed
|
||||
@summary[:error] = {
|
||||
class: e.class.name,
|
||||
message: e.message
|
||||
}
|
||||
raise e if raise_on_validation
|
||||
|
||||
@summary
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def execute_migration!
|
||||
ActiveRecord::Base.transaction do
|
||||
copy_custom_attribute_definitions!
|
||||
copy_labels!
|
||||
move_inbox_and_channel_config!
|
||||
copy_account_users!
|
||||
move_conversations!
|
||||
update_display_id_sequence!
|
||||
move_related_records!
|
||||
cleanup_orphaned_records!
|
||||
end
|
||||
end
|
||||
|
||||
def prepare_summary
|
||||
return if @prepared
|
||||
|
||||
load_source_and_destination
|
||||
build_scopes!
|
||||
validate_inputs
|
||||
build_preview!
|
||||
@prepared = true
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def build_preview!
|
||||
@summary = {
|
||||
source_account_id: @source_account_id,
|
||||
source_inbox_id: @source_inbox_id,
|
||||
destination_account_id: @destination_account_id,
|
||||
dry_run: @dry_run,
|
||||
batch_size: @batch_size,
|
||||
status: :unknown,
|
||||
validation_errors: @validation_errors,
|
||||
will: {},
|
||||
moved: {
|
||||
labels_copied: 0,
|
||||
account_users_copied: 0,
|
||||
conversations: 0,
|
||||
contacts: 0,
|
||||
contacts_duplicated: 0,
|
||||
contact_inboxes: 0,
|
||||
messages: 0,
|
||||
attachments: 0,
|
||||
notes: 0,
|
||||
csat_survey_responses: 0,
|
||||
mentions: 0,
|
||||
reporting_events: 0,
|
||||
conversation_participants: 0,
|
||||
inbox_members: 0,
|
||||
notifications_deleted: 0,
|
||||
applied_slas_deleted: 0,
|
||||
sla_events_deleted: 0,
|
||||
agent_bot_inboxes_deleted: 0,
|
||||
inbox_webhooks_deleted: 0,
|
||||
inbox_hooks_deleted: 0,
|
||||
campaigns_deleted: 0
|
||||
},
|
||||
custom_attribute_definitions: {
|
||||
source_total: 0,
|
||||
to_create: 0,
|
||||
skipped_existing: 0,
|
||||
skipped_reserved: 0
|
||||
}
|
||||
}
|
||||
|
||||
return if @summary[:validation_errors].present?
|
||||
|
||||
build_custom_attribute_migration_plan!
|
||||
build_preview_counts!
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def build_scopes!
|
||||
conversation_scope = source_inbox_conversations_scope
|
||||
|
||||
@conversation_ids = conversation_scope.pluck(:id)
|
||||
@source_contact_ids = conversation_scope.pluck(:contact_id).uniq.compact
|
||||
@source_contact_inbox_ids = conversation_scope.pluck(:contact_inbox_id).uniq.compact
|
||||
@source_contacts_with_other_inbox_conversations = source_contact_ids_in_other_inboxes
|
||||
|
||||
@source_contacts = Contact.where(id: @source_contact_ids).index_by(&:id)
|
||||
@source_contact_inboxes = ContactInbox.where(id: @source_contact_inbox_ids).index_by(&:id)
|
||||
@source_custom_attribute_definitions = CustomAttributeDefinition.where(
|
||||
account_id: @source_account_id,
|
||||
attribute_model: CUSTOM_ATTRIBUTE_MODELS
|
||||
).to_a
|
||||
end
|
||||
|
||||
def source_inbox_conversations_scope
|
||||
@source_inbox_conversations_scope ||= Conversation.where(
|
||||
account_id: @source_account_id,
|
||||
inbox_id: @source_inbox_id
|
||||
)
|
||||
end
|
||||
|
||||
def source_contact_ids_in_other_inboxes
|
||||
return [] if @source_contact_ids.blank?
|
||||
|
||||
Conversation.where(
|
||||
account_id: @source_account_id,
|
||||
contact_id: @source_contact_ids
|
||||
)
|
||||
.where.not(inbox_id: @source_inbox_id)
|
||||
.pluck(:contact_id)
|
||||
.uniq
|
||||
.compact
|
||||
end
|
||||
|
||||
def build_custom_attribute_migration_plan!
|
||||
@summary[:custom_attribute_definitions][:source_total] = @source_custom_attribute_definitions.size
|
||||
|
||||
return if @source_custom_attribute_definitions.empty?
|
||||
|
||||
destination_keys = CustomAttributeDefinition.where(
|
||||
account_id: @destination_account_id,
|
||||
attribute_model: CUSTOM_ATTRIBUTE_MODELS
|
||||
)
|
||||
destination_pairs = destination_keys.pluck(:attribute_model, :attribute_key)
|
||||
|
||||
@source_custom_attribute_definitions.each do |definition|
|
||||
if destination_pairs.include?([definition.attribute_model, definition.attribute_key])
|
||||
@summary[:custom_attribute_definitions][:skipped_existing] += 1
|
||||
next
|
||||
end
|
||||
|
||||
if reserved_custom_attribute?(definition)
|
||||
@summary[:custom_attribute_definitions][:skipped_reserved] += 1
|
||||
next
|
||||
end
|
||||
|
||||
@summary[:custom_attribute_definitions][:to_create] += 1
|
||||
end
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||
def build_preview_counts!
|
||||
messages_scope = Message.where(conversation_id: @conversation_ids)
|
||||
contact_scope_ids = @source_contact_ids
|
||||
|
||||
@summary[:will][:inbox] = {
|
||||
source_name: @source_inbox&.name,
|
||||
channel_type: @source_inbox&.channel_type,
|
||||
destination_account_name: @destination_account&.name
|
||||
}
|
||||
@summary[:will][:labels] = Label.where(account_id: @source_account_id).count
|
||||
@summary[:will][:account_users] = AccountUser.where(account_id: @source_account_id).count
|
||||
@summary[:will][:conversations] = @conversation_ids.size
|
||||
@summary[:will][:contacts] = contact_scope_ids.size
|
||||
@summary[:will][:contacts_to_duplicate] = build_contacts_to_duplicate_list
|
||||
@summary[:will][:contact_inboxes] = @source_contact_inbox_ids.size
|
||||
@summary[:will][:messages] = messages_scope.count
|
||||
@summary[:will][:attachments] = Attachment.joins(:message)
|
||||
.where(messages: { conversation_id: @conversation_ids })
|
||||
.count
|
||||
@summary[:will][:notes] = Note.where(contact_id: contact_scope_ids).count
|
||||
@summary[:will][:csat_survey_responses] = CsatSurveyResponse.where(conversation_id: @conversation_ids).count
|
||||
@summary[:will][:mentions] = Mention.where(conversation_id: @conversation_ids).count
|
||||
@summary[:will][:reporting_events] = ReportingEvent.where(conversation_id: @conversation_ids).count
|
||||
@summary[:will][:notifications] = Notification.where(primary_actor_type: 'Conversation', primary_actor_id: @conversation_ids).count
|
||||
@summary[:will][:conversation_participants] = ConversationParticipant.where(conversation_id: @conversation_ids).count
|
||||
@summary[:will][:inbox_members] = InboxMember.where(inbox_id: @source_inbox_id).count
|
||||
@summary[:will][:agent_bot_inboxes] = AgentBotInbox.where(inbox_id: @source_inbox_id).count
|
||||
@summary[:will][:inbox_webhooks] = Webhook.where(inbox_id: @source_inbox_id, webhook_type: :inbox_type).count
|
||||
@summary[:will][:inbox_hooks] = Integrations::Hook.where(inbox_id: @source_inbox_id, hook_type: :inbox).count
|
||||
@summary[:will][:campaigns] = Campaign.where(inbox_id: @source_inbox_id, account_id: @source_account_id).count
|
||||
|
||||
build_enterprise_preview_counts!
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||
|
||||
def build_contacts_to_duplicate_list
|
||||
return [] if @source_contacts_with_other_inbox_conversations.blank?
|
||||
|
||||
Contact.where(id: @source_contacts_with_other_inbox_conversations).map do |contact|
|
||||
{ id: contact.id, name: contact.name, email: contact.email, phone_number: contact.phone_number }
|
||||
end
|
||||
end
|
||||
|
||||
def build_enterprise_preview_counts!
|
||||
applied_sla_klass = 'AppliedSla'.safe_constantize
|
||||
sla_event_klass = 'SlaEvent'.safe_constantize
|
||||
|
||||
@summary[:will][:applied_slas] = applied_sla_klass ? applied_sla_klass.where(conversation_id: @conversation_ids).count : 0
|
||||
@summary[:will][:sla_events] = sla_event_klass ? sla_event_klass.where(conversation_id: @conversation_ids).count : 0
|
||||
end
|
||||
|
||||
def load_source_and_destination
|
||||
@validation_errors = []
|
||||
|
||||
@source_account = Account.find_by(id: @source_account_id)
|
||||
@destination_account = Account.find_by(id: @destination_account_id)
|
||||
@source_inbox = Inbox.find_by(id: @source_inbox_id)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/CyclomaticComplexity
|
||||
def validate_inputs
|
||||
@validation_errors << "Source account ##{@source_account_id} not found" if @source_account.blank?
|
||||
@validation_errors << "Destination account ##{@destination_account_id} not found" if @destination_account.blank?
|
||||
return if @validation_errors.present?
|
||||
|
||||
if @source_inbox.blank?
|
||||
@validation_errors << "Source inbox ##{@source_inbox_id} not found"
|
||||
return
|
||||
end
|
||||
|
||||
@validation_errors << 'Destination account must be different from source account' if @source_account_id.to_i == @destination_account_id.to_i
|
||||
|
||||
if @source_inbox.account_id != @source_account.id
|
||||
@validation_errors << "Source inbox ##{@source_inbox_id} does not belong to account ##{@source_account_id}"
|
||||
end
|
||||
|
||||
return unless destination_not_empty?
|
||||
|
||||
@validation_errors << "Destination account ##{@destination_account_id} is not empty: #{destination_occupancy_issues.join(', ')}"
|
||||
end
|
||||
# rubocop:enable Metrics/CyclomaticComplexity
|
||||
|
||||
def destination_not_empty?
|
||||
destination_occupancy_issues.present?
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
||||
def destination_occupancy_issues
|
||||
@destination_occupancy_issues ||= [
|
||||
('inboxes' if @destination_account.inboxes.exists?),
|
||||
('conversations' if @destination_account.conversations.exists?),
|
||||
('messages' if @destination_account.messages.exists?),
|
||||
('contacts' if @destination_account.contacts.exists?),
|
||||
('contact_inboxes' if contact_inboxes_exist_in_destination?),
|
||||
('notes' if @destination_account.notes.exists?),
|
||||
('campaigns' if @destination_account.campaigns.exists?)
|
||||
].compact
|
||||
end
|
||||
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
||||
|
||||
def contact_inboxes_exist_in_destination?
|
||||
ContactInbox.exists?(contact: @destination_account.contacts)
|
||||
end
|
||||
|
||||
def copy_labels!
|
||||
source_labels = Label.where(account_id: @source_account_id)
|
||||
return if source_labels.empty?
|
||||
|
||||
rows = source_labels.map do |label|
|
||||
{
|
||||
account_id: @destination_account_id,
|
||||
title: label.title,
|
||||
description: label.description,
|
||||
color: label.color,
|
||||
show_on_sidebar: label.show_on_sidebar,
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
}
|
||||
end
|
||||
|
||||
@logger.info "Copying #{rows.size} labels to destination account"
|
||||
result = Label.insert_all(rows, unique_by: %i[title account_id]) # rubocop:disable Rails/SkipsModelValidations
|
||||
@summary[:moved][:labels_copied] = result.count
|
||||
end
|
||||
|
||||
def copy_account_users!
|
||||
source_account_users = AccountUser.where(account_id: @source_account_id)
|
||||
return if source_account_users.empty?
|
||||
|
||||
rows = source_account_users.map do |au|
|
||||
{
|
||||
account_id: @destination_account_id,
|
||||
user_id: au.user_id,
|
||||
role: au.role,
|
||||
availability: AccountUser.availabilities[:offline],
|
||||
auto_offline: true,
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
}
|
||||
end
|
||||
|
||||
@logger.info "Copying #{rows.size} account users to destination account"
|
||||
result = AccountUser.insert_all(rows, unique_by: %i[account_id user_id]) # rubocop:disable Rails/SkipsModelValidations
|
||||
@summary[:moved][:account_users_copied] = result.count
|
||||
end
|
||||
|
||||
def move_inbox_and_channel_config!
|
||||
@logger.info "Moving inbox ##{@source_inbox_id} and channel config to account ##{@destination_account_id}"
|
||||
@source_inbox.update_columns(account_id: @destination_account_id)
|
||||
@source_inbox.channel.update_columns(account_id: @destination_account_id)
|
||||
end
|
||||
|
||||
def move_conversations!
|
||||
@logger.info "Moving #{@conversation_ids.size} conversations in batches of #{@batch_size}"
|
||||
source_inbox_conversations_scope
|
||||
.in_batches(of: @batch_size) do |conversation_batch|
|
||||
conversation_batch.each do |conversation|
|
||||
migrate_conversation!(conversation)
|
||||
end
|
||||
@logger.info " Migrated #{@summary[:moved][:conversations]} conversations so far"
|
||||
end
|
||||
|
||||
@summary[:moved][:contact_inboxes] = @source_contact_inbox_ids.size
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||
def migrate_conversation!(conversation)
|
||||
source_contact = @source_contacts[conversation.contact_id]
|
||||
|
||||
raise "Source conversation ##{conversation.id} has missing contact reference" unless source_contact
|
||||
|
||||
destination_contact = destination_contact_for(source_contact)
|
||||
|
||||
update_attrs = {
|
||||
account_id: @destination_account_id,
|
||||
contact_id: destination_contact.id,
|
||||
team_id: nil,
|
||||
assignee_agent_bot_id: nil,
|
||||
campaign_id: nil,
|
||||
sla_policy_id: nil
|
||||
}
|
||||
|
||||
# Only remap contact_inbox if the conversation has one
|
||||
if conversation.contact_inbox_id.present?
|
||||
source_contact_inbox = @source_contact_inboxes[conversation.contact_inbox_id]
|
||||
raise "Source conversation ##{conversation.id} has missing contact inbox reference" unless source_contact_inbox
|
||||
|
||||
destination_contact_inbox = destination_contact_inbox_for(source_contact_inbox, destination_contact)
|
||||
update_attrs[:contact_inbox_id] = destination_contact_inbox.id
|
||||
end
|
||||
|
||||
conversation.update_columns(update_attrs)
|
||||
|
||||
source_contact_processed = source_contact_id_processed?(conversation.contact_id)
|
||||
@source_to_destination_contact[conversation.contact_id] = destination_contact
|
||||
@summary[:moved][:contacts] += 1 unless source_contact_processed
|
||||
@summary[:moved][:conversations] += 1
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||
|
||||
def update_display_id_sequence!
|
||||
max_display_id = Conversation.where(account_id: @destination_account_id).maximum(:display_id) || 0
|
||||
return if max_display_id.zero?
|
||||
|
||||
quoted_id = ActiveRecord::Base.connection.quote(@destination_account_id)
|
||||
ActiveRecord::Base.connection.execute("SELECT setval('conv_dpid_seq_' || #{quoted_id}, #{max_display_id})")
|
||||
@logger.info "Updated display_id sequence to #{max_display_id}"
|
||||
end
|
||||
|
||||
def destination_contact_for(source_contact)
|
||||
return @source_to_destination_contact[source_contact.id] if source_contact_id_processed?(source_contact.id)
|
||||
|
||||
if contact_needs_duplication?(source_contact.id)
|
||||
duplicate_contact(source_contact)
|
||||
else
|
||||
source_contact.update_columns(account_id: @destination_account_id)
|
||||
@source_to_destination_contact[source_contact.id] = source_contact
|
||||
source_contact
|
||||
end
|
||||
end
|
||||
|
||||
def contact_needs_duplication?(contact_id)
|
||||
@source_contacts_with_other_inbox_conversations.include?(contact_id)
|
||||
end
|
||||
|
||||
def duplicate_contact(source_contact)
|
||||
new_contact = source_contact.dup
|
||||
new_contact.account_id = @destination_account_id
|
||||
new_contact.company_id = nil
|
||||
new_contact.save!
|
||||
|
||||
@source_to_destination_contact[source_contact.id] = new_contact
|
||||
@summary[:moved][:contacts_duplicated] += 1
|
||||
new_contact
|
||||
end
|
||||
|
||||
def source_contact_id_processed?(source_contact_id)
|
||||
@source_to_destination_contact.key?(source_contact_id)
|
||||
end
|
||||
|
||||
def destination_contact_inbox_for(source_contact_inbox, destination_contact)
|
||||
existing = @destination_contact_inboxes_by_source_id[source_contact_inbox.source_id]
|
||||
return existing if existing
|
||||
|
||||
source_contact_inbox.update_columns(
|
||||
contact_id: destination_contact.id,
|
||||
inbox_id: @source_inbox.id
|
||||
)
|
||||
destination_inbox = source_contact_inbox
|
||||
|
||||
@destination_contact_inboxes_by_source_id[source_contact_inbox.source_id] = destination_inbox
|
||||
|
||||
destination_inbox
|
||||
end
|
||||
|
||||
def move_related_records!
|
||||
@logger.info 'Moving messages and attachments'
|
||||
message_scope = Message.where(conversation_id: @conversation_ids)
|
||||
@summary[:moved][:messages] = message_scope.update_all(account_id: @destination_account_id)
|
||||
|
||||
@summary[:moved][:attachments] = Attachment.where(message_id: message_scope.select(:id))
|
||||
.update_all(account_id: @destination_account_id)
|
||||
|
||||
move_notes!
|
||||
move_csat_survey_responses!
|
||||
move_mentions!
|
||||
move_reporting_events!
|
||||
end
|
||||
|
||||
def move_notes!
|
||||
@logger.info 'Moving notes'
|
||||
notes_scope = Note.where(contact_id: @source_contact_ids)
|
||||
@summary[:moved][:notes] = notes_scope.update_all(account_id: @destination_account_id, user_id: nil)
|
||||
end
|
||||
|
||||
def move_csat_survey_responses!
|
||||
@logger.info 'Moving CSAT survey responses'
|
||||
csat_scope = CsatSurveyResponse.where(conversation_id: @conversation_ids)
|
||||
@summary[:moved][:csat_survey_responses] = csat_scope.update_all(account_id: @destination_account_id)
|
||||
end
|
||||
|
||||
def move_mentions!
|
||||
@logger.info 'Moving mentions'
|
||||
@summary[:moved][:mentions] = Mention.where(conversation_id: @conversation_ids)
|
||||
.update_all(account_id: @destination_account_id)
|
||||
end
|
||||
|
||||
def move_reporting_events!
|
||||
@logger.info 'Moving reporting events'
|
||||
@summary[:moved][:reporting_events] = ReportingEvent.where(conversation_id: @conversation_ids)
|
||||
.update_all(account_id: @destination_account_id, user_id: nil)
|
||||
end
|
||||
|
||||
# -- Cleanup methods for orphaned records --
|
||||
|
||||
def cleanup_orphaned_records!
|
||||
@logger.info 'Cleaning up orphaned records'
|
||||
cleanup_notifications!
|
||||
migrate_conversation_participants!
|
||||
cleanup_sla_records!
|
||||
migrate_inbox_members!
|
||||
cleanup_agent_bot_inbox!
|
||||
cleanup_inbox_webhooks!
|
||||
cleanup_inbox_hooks!
|
||||
cleanup_campaigns!
|
||||
end
|
||||
|
||||
def cleanup_notifications!
|
||||
@logger.info ' Deleting notifications for migrated conversations'
|
||||
notifications_scope = Notification.where(
|
||||
primary_actor_type: 'Conversation',
|
||||
primary_actor_id: @conversation_ids
|
||||
)
|
||||
@summary[:moved][:notifications_deleted] = notifications_scope.count
|
||||
notifications_scope.delete_all
|
||||
end
|
||||
|
||||
def migrate_conversation_participants!
|
||||
@logger.info ' Migrating conversation participants'
|
||||
@summary[:moved][:conversation_participants] = ConversationParticipant.where(conversation_id: @conversation_ids)
|
||||
.update_all(account_id: @destination_account_id)
|
||||
end
|
||||
|
||||
def cleanup_sla_records!
|
||||
applied_sla_klass = 'AppliedSla'.safe_constantize
|
||||
sla_event_klass = 'SlaEvent'.safe_constantize
|
||||
|
||||
if sla_event_klass
|
||||
@logger.info ' Deleting SLA events'
|
||||
@summary[:moved][:sla_events_deleted] = sla_event_klass.where(conversation_id: @conversation_ids).delete_all
|
||||
end
|
||||
|
||||
return unless applied_sla_klass
|
||||
|
||||
@logger.info ' Deleting applied SLAs'
|
||||
@summary[:moved][:applied_slas_deleted] = applied_sla_klass.where(conversation_id: @conversation_ids).delete_all
|
||||
end
|
||||
|
||||
def migrate_inbox_members!
|
||||
# InboxMember has no account_id — rows just reference inbox_id + user_id.
|
||||
# The inbox is already moved and users are copied, so these rows are valid as-is.
|
||||
@summary[:moved][:inbox_members] = InboxMember.where(inbox_id: @source_inbox_id).count
|
||||
@logger.info " Retained #{@summary[:moved][:inbox_members]} inbox members"
|
||||
end
|
||||
|
||||
def cleanup_agent_bot_inbox!
|
||||
@logger.info ' Deleting agent bot inbox associations'
|
||||
@summary[:moved][:agent_bot_inboxes_deleted] = AgentBotInbox.where(inbox_id: @source_inbox_id).delete_all
|
||||
end
|
||||
|
||||
def cleanup_inbox_webhooks!
|
||||
@logger.info ' Deleting inbox-scoped webhooks'
|
||||
@summary[:moved][:inbox_webhooks_deleted] = Webhook.where(inbox_id: @source_inbox_id, webhook_type: :inbox_type).delete_all
|
||||
end
|
||||
|
||||
def cleanup_inbox_hooks!
|
||||
@logger.info ' Deleting inbox-scoped integration hooks'
|
||||
@summary[:moved][:inbox_hooks_deleted] = Integrations::Hook.where(inbox_id: @source_inbox_id, hook_type: :inbox).delete_all
|
||||
end
|
||||
|
||||
def cleanup_campaigns!
|
||||
@logger.info ' Deleting campaigns for inbox'
|
||||
@summary[:moved][:campaigns_deleted] = Campaign.where(inbox_id: @source_inbox_id, account_id: @destination_account_id).delete_all
|
||||
end
|
||||
|
||||
def copy_custom_attribute_definitions!
|
||||
return if @source_custom_attribute_definitions.empty?
|
||||
|
||||
@logger.info "Copying #{@summary[:custom_attribute_definitions][:to_create]} custom attribute definitions"
|
||||
|
||||
@source_custom_attribute_definitions.each do |definition|
|
||||
next if custom_definition_exists?(definition)
|
||||
next if reserved_custom_attribute?(definition)
|
||||
|
||||
destination_definition = definition.attributes.slice(
|
||||
'attribute_description',
|
||||
'attribute_display_name',
|
||||
'attribute_display_type',
|
||||
'attribute_key',
|
||||
'attribute_model',
|
||||
'attribute_values',
|
||||
'default_value',
|
||||
'regex_cue',
|
||||
'regex_pattern'
|
||||
)
|
||||
|
||||
destination_definition['account_id'] = @destination_account_id
|
||||
CustomAttributeDefinition.create!(destination_definition)
|
||||
end
|
||||
end
|
||||
|
||||
def custom_definition_exists?(definition)
|
||||
CustomAttributeDefinition.exists?(
|
||||
account_id: @destination_account_id,
|
||||
attribute_model: definition.attribute_model,
|
||||
attribute_key: definition.attribute_key
|
||||
)
|
||||
end
|
||||
|
||||
def reserved_custom_attribute?(definition)
|
||||
RESERVED_CUSTOM_ATTRIBUTE_KEYS[definition.attribute_model.to_sym]&.include?(definition.attribute_key)
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ClassLength
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,10 +10,12 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
||||
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
|
||||
let(:web_widget_inbox) { create(:inbox, account: account) }
|
||||
let(:mock_service) { instance_double(Whatsapp::CsatTemplateService) }
|
||||
let(:analysis_service) { instance_double(CsatTemplateUtilityAnalysisService) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
|
||||
allow(Whatsapp::CsatTemplateService).to receive(:new).and_return(mock_service)
|
||||
allow(CsatTemplateUtilityAnalysisService).to receive(:new).and_return(analysis_service)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
|
||||
@@ -380,4 +382,93 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template/analyze' do
|
||||
let(:valid_template_params) do
|
||||
{
|
||||
template: {
|
||||
message: 'How would you rate your experience?',
|
||||
button_text: 'Rate Us',
|
||||
language: 'en'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when captain_integration feature is disabled' do
|
||||
before do
|
||||
account.disable_features!('captain_integration')
|
||||
end
|
||||
|
||||
it 'returns forbidden' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('Captain is required for template analysis')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain_integration feature is enabled' do
|
||||
before do
|
||||
account.enable_features!('captain_integration')
|
||||
account.reload
|
||||
end
|
||||
|
||||
it 'returns analysis response' do
|
||||
allow(analysis_service).to receive(:perform).and_return({
|
||||
classification: 'LIKELY_UTILITY',
|
||||
optimized_message: 'Your support request has been closed.'
|
||||
})
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = response.parsed_body
|
||||
expect(response_data['classification']).to eq('LIKELY_UTILITY')
|
||||
expect(response_data['optimized_message']).to eq('Your support request has been closed.')
|
||||
end
|
||||
|
||||
it 'returns error when message is missing' do
|
||||
invalid_params = { template: { button_text: 'Rate Us', language: 'en' } }
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: invalid_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Message is required')
|
||||
end
|
||||
|
||||
it 'returns unauthorized when agent is not assigned to inbox' do
|
||||
other_agent = create(:user, account: account, role: :agent)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
|
||||
headers: other_agent.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'allows access when agent is assigned to inbox' do
|
||||
allow(analysis_service).to receive(:perform).and_return({
|
||||
classification: 'LIKELY_UTILITY',
|
||||
optimized_message: 'Your support request has been closed.'
|
||||
})
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -74,12 +74,11 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
end
|
||||
|
||||
it 'runs agent with extracted user message and context' do
|
||||
expected_context = {
|
||||
expected_context = hash_including(
|
||||
session_id: "#{account.id}_#{conversation.display_id}",
|
||||
conversation_history: [
|
||||
{ role: :user, content: 'Hello there', agent_name: nil },
|
||||
{ role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' },
|
||||
{ role: :user, content: 'I need help with my account', agent_name: nil }
|
||||
{ role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' }
|
||||
],
|
||||
state: hash_including(
|
||||
account_id: account.id,
|
||||
@@ -87,7 +86,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
conversation: hash_including(id: conversation.id),
|
||||
contact: hash_including(id: contact.id)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
expect(mock_runner).to receive(:run).with(
|
||||
'I need help with my account',
|
||||
@@ -98,6 +97,71 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
|
||||
context 'when the latest user message is multimodal' do
|
||||
let(:multimodal_message_history) do
|
||||
[
|
||||
{ role: 'assistant', content: 'Please share a screenshot' },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'What does this error mean?' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
it 'passes image attachments to the runner input' do
|
||||
expect(mock_runner).to receive(:run) do |input, context:, max_turns:|
|
||||
expect(input).to be_a(RubyLLM::Content)
|
||||
expect(input.text).to eq('What does this error mean?')
|
||||
expect(input.attachments.first.source.to_s).to eq('https://example.com/error.png')
|
||||
expect(context[:conversation_history]).to eq([{ role: :assistant, content: 'Please share a screenshot', agent_name: nil }])
|
||||
expect(max_turns).to eq(100)
|
||||
end
|
||||
|
||||
service.generate_response(message_history: multimodal_message_history)
|
||||
end
|
||||
|
||||
it 'preserves multimodal content in earlier history messages' do
|
||||
history_with_prior_image = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Here is my error screenshot' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
|
||||
]
|
||||
},
|
||||
{ role: 'assistant', content: 'I see the error. Try restarting.' },
|
||||
{ role: 'user', content: 'It still does not work' }
|
||||
]
|
||||
|
||||
expect(mock_runner).to receive(:run) do |input, context:, max_turns:|
|
||||
expect(input).to eq('It still does not work')
|
||||
# The earlier user message with the image should preserve the multimodal array
|
||||
first_history_msg = context[:conversation_history].first
|
||||
expect(first_history_msg[:content]).to be_a(Array)
|
||||
expect(first_history_msg[:content]).to include(
|
||||
{ type: 'text', text: 'Here is my error screenshot' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
|
||||
)
|
||||
expect(max_turns).to eq(100)
|
||||
end
|
||||
|
||||
service.generate_response(message_history: history_with_prior_image)
|
||||
end
|
||||
|
||||
it 'stores multimodal trace payloads in runner context' do
|
||||
expect(mock_runner).to receive(:run) do |_input, context:, max_turns:|
|
||||
expect(context[:captain_v2_trace_input]).to include('image_url')
|
||||
expect(context[:captain_v2_trace_current_input]).to include('image_url')
|
||||
expect(max_turns).to eq(100)
|
||||
end
|
||||
|
||||
service.generate_response(message_history: multimodal_message_history)
|
||||
end
|
||||
end
|
||||
|
||||
it 'processes and formats agent result' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
@@ -197,22 +261,21 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
end
|
||||
|
||||
context 'with multimodal content' do
|
||||
let(:multimodal_message_history) do
|
||||
let(:multimodal_content) do
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Can you help with this image?' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
|
||||
]
|
||||
}
|
||||
{ type: 'text', text: 'Can you help with this image?' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
|
||||
]
|
||||
end
|
||||
|
||||
it 'extracts text content from multimodal messages' do
|
||||
let(:multimodal_message_history) do
|
||||
[{ role: 'user', content: multimodal_content }]
|
||||
end
|
||||
|
||||
it 'preserves multimodal arrays in conversation history for image context retention' do
|
||||
context = service.send(:build_context, multimodal_message_history)
|
||||
|
||||
expect(context[:conversation_history].first[:content]).to eq('Can you help with this image?')
|
||||
expect(context[:conversation_history].first[:content]).to eq(multimodal_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -225,6 +288,24 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
|
||||
expect(result).to eq('I need help with my account')
|
||||
end
|
||||
|
||||
it 'returns multimodal content with image attachments for the runner input' do
|
||||
multimodal_message_history = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Can you check this screenshot?' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = service.send(:extract_last_user_message, multimodal_message_history)
|
||||
|
||||
expect(result).to be_a(RubyLLM::Content)
|
||||
expect(result.text).to eq('Can you check this screenshot?')
|
||||
expect(result.attachments.first.source.to_s).to eq('https://example.com/image.jpg')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_text_from_content' do
|
||||
@@ -256,6 +337,28 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#dynamic_trace_attributes' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
|
||||
|
||||
it 'adds serialized trace input attributes when present in context' do
|
||||
context = {
|
||||
state: {
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id,
|
||||
conversation: { id: conversation.id, display_id: conversation.display_id }
|
||||
},
|
||||
captain_v2_trace_input: '[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.com/image.jpg"}}]}]'
|
||||
}
|
||||
context_wrapper = Struct.new(:context).new(context)
|
||||
|
||||
attributes = service.send(:dynamic_trace_attributes, context_wrapper)
|
||||
|
||||
expect(attributes['langfuse.trace.input']).to include('image_url')
|
||||
expect(attributes['langfuse.observation.input']).to include('image_url')
|
||||
expect(attributes['langfuse.user.id']).to eq(account.id.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_state' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::CsatUtilityAnalysisService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account, message: 'Test message', language: 'en', baseline: {}) }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
allow(service).to receive(:make_api_call).and_return({
|
||||
message: '{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}'
|
||||
})
|
||||
end
|
||||
|
||||
it 'returns parsed payload and preserves raw message for usage metering' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:classification]).to eq('LIKELY_UTILITY')
|
||||
expect(result[:optimized_message]).to eq('Utility-safe message')
|
||||
expect(result[:message]).to eq('{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CsatTemplateUtilityAnalysisService do
|
||||
let(:account) { build_stubbed(:account) }
|
||||
let(:inbox) { build_stubbed(:inbox) }
|
||||
let(:llm_service) { instance_double(Captain::CsatUtilityAnalysisService) }
|
||||
|
||||
before do
|
||||
allow(Captain::CsatUtilityAnalysisService).to receive(:new).and_return(llm_service)
|
||||
allow(llm_service).to receive(:perform).and_return({ error: 'LLM unavailable' })
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when message is utility-compatible' do
|
||||
it 'returns likely utility classification and keeps original message' do
|
||||
message = 'Your support request has been closed. If you still need help, reply to this message.'
|
||||
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
|
||||
|
||||
expect(result[:classification]).to eq('LIKELY_UTILITY')
|
||||
expect(result[:optimized_message]).to eq(message)
|
||||
expect(result.keys).to contain_exactly(:classification, :optimized_message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains marketing intent' do
|
||||
it 'returns likely marketing classification with utility-safe rewrite' do
|
||||
message = 'Please rate us and check out our special offer with a discount.'
|
||||
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
|
||||
|
||||
expect(result[:classification]).to eq('LIKELY_MARKETING')
|
||||
expect(result[:optimized_message]).to include('support request')
|
||||
expect(result[:optimized_message]).to include('reply to this message')
|
||||
expect(result.keys).to contain_exactly(:classification, :optimized_message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when language is non-English and fallback rewrite is used' do
|
||||
it 'returns English rewrite content' do
|
||||
message = 'Tu caso está cerrado. Califícanos y no te pierdas nuestra oferta.'
|
||||
result = described_class.new(account: account, inbox: inbox, message: message, language: 'es').perform
|
||||
|
||||
expect(result[:optimized_message]).to include('Your support request has been closed.')
|
||||
expect(result[:optimized_message]).to include('If you still need help')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when llm returns inconsistent marketing classification' do
|
||||
it 'keeps likely marketing classification' do
|
||||
allow(llm_service).to receive(:perform).and_return({
|
||||
classification: 'LIKELY_MARKETING',
|
||||
optimized_message: 'Your support request has been closed.'
|
||||
})
|
||||
|
||||
message = "Your case is closed. Don't miss our limited-time premium offer. Rate us below."
|
||||
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
|
||||
|
||||
expect(result[:classification]).to eq('LIKELY_MARKETING')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rules classify as marketing' do
|
||||
it 'short-circuits without calling llm' do
|
||||
expect(llm_service).not_to receive(:perform)
|
||||
|
||||
message = 'Your request is closed. Special offer: subscribe now and save.'
|
||||
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
|
||||
|
||||
expect(result[:classification]).to eq('LIKELY_MARKETING')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rules classify as marketing for plural promo terms' do
|
||||
it 'keeps likely marketing classification from baseline rules' do
|
||||
message = 'Thanks for contacting us. Rate us and check out our new plans with special discounts.'
|
||||
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
|
||||
|
||||
expect(result[:classification]).to eq('LIKELY_MARKETING')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -119,7 +119,7 @@ RSpec.describe Whatsapp::CsatTemplateService do
|
||||
expect(result).to eq({
|
||||
name: expected_template_name,
|
||||
language: 'en',
|
||||
category: 'MARKETING',
|
||||
category: 'UTILITY',
|
||||
components: [
|
||||
{
|
||||
type: 'BODY',
|
||||
@@ -169,7 +169,7 @@ RSpec.describe Whatsapp::CsatTemplateService do
|
||||
expected_body = {
|
||||
name: expected_template_name,
|
||||
language: 'en',
|
||||
category: 'MARKETING',
|
||||
category: 'UTILITY',
|
||||
components: [
|
||||
{
|
||||
type: 'BODY',
|
||||
|
||||
@@ -148,6 +148,83 @@ export const icons = {
|
||||
width: 7,
|
||||
height: 11,
|
||||
},
|
||||
'empty-assignee': {
|
||||
body: `<g fill="none" stroke="currentColor">
|
||||
<path d="M10.29 3.16394C11.4196 2.94535 12.5805 2.94535 13.71 3.16394" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.71 20.8362C12.5805 21.0548 11.4196 21.0548 10.29 20.8362" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.0479 4.54907C18.0032 5.19637 18.8251 6.02134 19.4688 6.97906" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.16394 13.7101C2.94535 12.5806 2.94535 11.4196 3.16394 10.2901" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.4515 17.0482C18.8042 18.0035 17.9792 18.8254 17.0215 19.4691" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.8359 10.2901C21.0545 11.4196 21.0545 12.5806 20.8359 13.7101" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.54883 6.95205C5.19612 5.99673 6.0211 5.17481 6.97881 4.53107" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.95223 19.4512C5.99692 18.8039 5.175 17.9789 4.53125 17.0212" stroke-width="1.79999" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 14.25C13.7259 14.25 15.125 12.8509 15.125 11.125C15.125 9.39911 13.7259 8 12 8C10.2741 8 8.875 9.39911 8.875 11.125C8.875 12.8509 10.2741 14.25 12 14.25Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 19.25C17 17.9239 16.4732 16.6521 15.5355 15.7144C14.5979 14.7767 13.3261 14.25 12 14.25C10.6739 14.25 9.40215 14.7767 8.46447 15.7144C7.52678 16.6521 7 17.9239 7 19.25" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g>`,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
party: {
|
||||
body: `<g fill="currentColor" stroke="none"><path d="M8.023 2.426a.6.6 0 0 1 .4.748l-.562 1.85a.6.6 0 0 1-1.15-.348l.563-1.85a.6.6 0 0 1 .75-.4M12.8 3a.6.6 0 1 0-1.2 0v.2h-.2a.6.6 0 0 0 0 1.2h.2v.2a.6.6 0 0 0 1.2 0v-.2h.2a.6.6 0 1 0 0-1.2h-.2z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.645 6.197a1.4 1.4 0 0 0-2.268.416L2.703 10.35a2.2 2.2 0 0 0 .452 2.456l.04.04a2.2 2.2 0 0 0 2.457.452l3.734-1.675a1.4 1.4 0 0 0 .417-2.267zm-1.172.908a.2.2 0 0 1 .324-.06l3.158 3.158a.2.2 0 0 1-.06.324l-3.734 1.674a1 1 0 0 1-1.116-.205l-.041-.04a1 1 0 0 1-.206-1.117z"/><path d="M4.6 2.4a.6.6 0 0 1 .6.6v.2h.2a.6.6 0 0 1 0 1.2h-.2v.2a.6.6 0 0 1-1.2 0v-.2h-.2a.6.6 0 1 1 0-1.2H4V3a.6.6 0 0 1 .6-.6m8.2 8.2a.6.6 0 0 0-1.2 0v.2h-.2a.6.6 0 1 0 0 1.2h.2v.2a.6.6 0 0 0 1.2 0V12h.2a.6.6 0 1 0 0-1.2h-.2zm-1.976-4.576a.6.6 0 0 0-.848-.848l-.8.8a.6.6 0 0 0 .848.848zm2.438 2.802a.6.6 0 1 0-.33-1.154l-1.874.538a.6.6 0 1 0 .33 1.153z"/></g>`,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
'expand-list': {
|
||||
body: `<g fill="none" stroke="currentColor"><path d="M1.333 12V4a2 2 0 0 1 2-2h1.334a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H3.333a2 2 0 0 1-2-2Z" stroke-width="1.2"/><path d="M10.667 2H12a2.667 2.667 0 0 1 2.667 2.667v6.666A2.667 2.667 0 0 1 12 14h-1.333m-4-6H12m0 0-2-2m2 2-2 2" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></g>`,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
hash: {
|
||||
body: `<g fill="currentColor" stroke="none"><path d="M11.8125 8.3125H9.1875V5.6875H11.8125C11.9285 5.6875 12.0398 5.64141 12.1219 5.55936C12.2039 5.47731 12.25 5.36603 12.25 5.25C12.25 5.13397 12.2039 5.02269 12.1219 4.94064C12.0398 4.85859 11.9285 4.8125 11.8125 4.8125H9.1875V2.1875C9.1875 2.07147 9.14141 1.96019 9.05936 1.87814C8.97731 1.79609 8.86603 1.75 8.75 1.75C8.63397 1.75 8.52269 1.79609 8.44064 1.87814C8.35859 1.96019 8.3125 2.07147 8.3125 2.1875V4.8125H5.6875V2.1875C5.6875 2.07147 5.64141 1.96019 5.55936 1.87814C5.47731 1.79609 5.36603 1.75 5.25 1.75C5.13397 1.75 5.02269 1.79609 4.94064 1.87814C4.85859 1.96019 4.8125 2.07147 4.8125 2.1875V4.8125H2.1875C2.07147 4.8125 1.96019 4.85859 1.87814 4.94064C1.79609 5.02269 1.75 5.13397 1.75 5.25C1.75 5.36603 1.79609 5.47731 1.87814 5.55936C1.96019 5.64141 2.07147 5.6875 2.1875 5.6875H4.8125V8.3125H2.1875C2.07147 8.3125 1.96019 8.35859 1.87814 8.44064C1.79609 8.52269 1.75 8.63397 1.75 8.75C1.75 8.86603 1.79609 8.97731 1.87814 9.05936C1.96019 9.14141 2.07147 9.1875 2.1875 9.1875H4.8125V11.8125C4.8125 11.9285 4.85859 12.0398 4.94064 12.1219C5.02269 12.2039 5.13397 12.25 5.25 12.25C5.36603 12.25 5.47731 12.2039 5.55936 12.1219C5.64141 12.0398 5.6875 11.9285 5.6875 11.8125V9.1875H8.3125V11.8125C8.3125 11.9285 8.35859 12.0398 8.44064 12.1219C8.52269 12.2039 8.63397 12.25 8.75 12.25C8.86603 12.25 8.97731 12.2039 9.05936 12.1219C9.14141 12.0398 9.1875 11.9285 9.1875 11.8125V9.1875H11.8125C11.9285 9.1875 12.0398 9.14141 12.1219 9.05936C12.2039 8.97731 12.25 8.86603 12.25 8.75C12.25 8.63397 12.2039 8.52269 12.1219 8.44064C12.0398 8.35859 11.9285 8.3125 11.8125 8.3125ZM5.6875 8.3125V5.6875H8.3125V8.3125H5.6875Z" fill="currentColor"/></g>`,
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
|
||||
/** Conversation Status Starts */
|
||||
'status-empty': {
|
||||
body: `<path d="M12 3a9 9 0 0 1 6.642 15.075" stroke="#60646c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.7 12 12 9.3 9.3 12m2.7 2.7V9.3" stroke="#60646c" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.45 9.188a9 9 0 0 0-.45 2.7m.747 3.712a9 9 0 0 0 2.187 3.06M5.372 5.911a9 9 0 0 1 .802-.77M8.98 20.478a9 9 0 0 0 6.867-.342" stroke="#60646c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
'status-pending': {
|
||||
body: `<mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><path fill="#d9d9d9" d="M0 0h24v24H0z"/></mask><g mask="url(#a)" stroke="#b9bbc6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.164a9 9 0 0 1 3.42 0m0 17.672a9 9 0 0 1-3.42 0M17.048 4.55a9 9 0 0 1 2.42 2.43M3.164 13.71a9 9 0 0 1 0-3.42m16.288 6.758a9 9 0 0 1-2.43 2.421m3.814-9.179a9 9 0 0 1 0 3.42M4.549 6.952a9 9 0 0 1 2.43-2.421m-.027 14.92a9 9 0 0 1-2.42-2.43"/></g>`,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
'status-open': {
|
||||
body: `<mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><path fill="#d9d9d9" d="M0 0h24v24H0z"/></mask><g mask="url(#a)"><path d="M4.188 18.25q2.187 2.724 5.612 3.5a.89.89 0 0 0 .837-.176.99.99 0 0 0 .363-.8q0-.35-.2-.625a.88.88 0 0 0-.55-.35q-2.75-.625-4.5-2.8T4 12t1.75-5 4.5-2.8a.88.88 0 0 0 .55-.35q.2-.275.2-.625 0-.5-.363-.8A.89.89 0 0 0 9.8 2.25q-3.424.776-5.613 3.5Q2 8.474 2 12t2.188 6.25m9.175 3.324q.361.3.837.175.825-.2 1.613-.525.787-.325 1.512-.775a.99.99 0 0 0 .45-.737.96.96 0 0 0-.3-.813 1.02 1.02 0 0 0-.588-.3.8.8 0 0 0-.612.15q-.575.375-1.212.638a8 8 0 0 1-1.313.412.88.88 0 0 0-.55.35q-.2.275-.2.625 0 .5.363.8m5.237-4.662q.05.337.3.587a.96.96 0 0 0 .813.3 1 1 0 0 0 .737-.45q.45-.724.775-1.512a11 11 0 0 0 .525-1.613.89.89 0 0 0-.175-.837.99.99 0 0 0-.8-.363q-.35 0-.625.188a.83.83 0 0 0-.35.537 8.4 8.4 0 0 1-.413 1.325 7 7 0 0 1-.637 1.225.8.8 0 0 0-.15.613m1.55-6.112q.275.199.625.2.5 0 .8-.363t.175-.838a11 11 0 0 0-.525-1.612 10.4 10.4 0 0 0-.775-1.513q-.275-.425-.738-.475a.96.96 0 0 0-.812.3 1.02 1.02 0 0 0-.3.588.88.88 0 0 0 .15.637q.375.575.637 1.213.263.637.413 1.312.075.35.35.55m-5.087-6.187q.638.262 1.212.637.3.201.638.15.336-.05.587-.3a.96.96 0 0 0 .3-.812q-.05-.463-.475-.738a10.4 10.4 0 0 0-1.512-.775A11 11 0 0 0 14.2 2.25a.89.89 0 0 0-.837.175.99.99 0 0 0-.363.8q0 .35.2.625t.55.35q.674.15 1.313.413M11.913 7" fill="#2781f6"/><path d="M12.001 12.902a.9.9 0 1 0 0-1.8.9.9 0 0 0 0 1.8" stroke="#2781f6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></g>`,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
'status-snoozed': {
|
||||
body: `<mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><path fill="#d9d9d9" d="M0 0h24v24H0z"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M15.754 21.267a9.7 9.7 0 0 0 3.313-2.2 9.7 9.7 0 0 0 2.2-3.313Q22 13.908 22 12t-.733-3.754a9.7 9.7 0 0 0-2.2-3.313 9.7 9.7 0 0 0-3.313-2.2A10.1 10.1 0 0 0 12 2q-1.909 0-3.754.733a9.7 9.7 0 0 0-3.313 2.2 9.7 9.7 0 0 0-2.2 3.313A10.1 10.1 0 0 0 2 12q0 1.909.733 3.754a9.7 9.7 0 0 0 2.2 3.313 9.7 9.7 0 0 0 3.313 2.2Q10.09 22 12 22t3.754-.733m1.972-5.272a6.2 6.2 0 0 1-.779.952 6.8 6.8 0 0 1-2.319 1.54A7.1 7.1 0 0 1 12 19q-1.336 0-2.628-.513a6.8 6.8 0 0 1-2.319-1.54 6.8 6.8 0 0 1-1.54-2.319A7.1 7.1 0 0 1 5 12q0-1.336.513-2.628a6.8 6.8 0 0 1 1.54-2.319 6.2 6.2 0 0 1 2.053-1.36.706.706 0 0 1 .915.42.8.8 0 0 1 .037.31 6.8 6.8 0 0 0 .334 3.017A6.7 6.7 0 0 0 12 12a6.8 6.8 0 0 0 2.56 1.62 6.5 6.5 0 0 0 3.005.334.66.66 0 0 1 .581.185q.15.15.186.36a.77.77 0 0 1-.037.407q-.222.57-.569 1.089" fill="#ffc53d"/></g>`,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
'status-resolved': {
|
||||
body: `<path d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="#0D9B8A" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M9.2998 12L11.0998 13.8L14.6998 10.2" stroke="#0D9B8A" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
/** Ends */
|
||||
|
||||
/** Conversation Priority Starts */
|
||||
'priority-empty': {
|
||||
body: `<mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><path fill="currentColor" d="M0 0h24v24H0z"/></mask><g mask="url(#a)" fill="currentColor"><rect x="4" y="12" width="4" height="8" rx="2"/><rect x="10" y="8" width="4" height="12" rx="2"/><rect x="16" y="4" width="4" height="16" rx="2"/></g>`,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
'priority-low': {
|
||||
body: `<mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><path fill="#d9d9d9" d="M0 0h24v24H0z"/></mask><g mask="url(#a)"><rect x="4" y="12" width="4" height="8" rx="2" fill="#ffc53d"/><rect x="10" y="8" width="4" height="12" rx="2" fill="#e4e4e9"/><rect x="16" y="4" width="4" height="16" rx="2" fill="#e4e4e9"/></g>`,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
'priority-medium': {
|
||||
body: `<mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><path fill="#d9d9d9" d="M0 0h24v24H0z"/></mask><g mask="url(#a)"><rect x="4" y="12" width="4" height="8" rx="2" fill="#ffc53d"/><rect x="10" y="8" width="4" height="12" rx="2" fill="#ffc53d"/><rect x="16" y="4" width="4" height="16" rx="2" fill="#e4e4e9"/></g>`,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
'priority-high': {
|
||||
body: `<mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><path fill="#ed8a5c" d="M0 0h24v24H0z"/></mask><g mask="url(#a)" fill="#ffc53d"><rect x="4" y="12" width="4" height="8" rx="2"/><rect x="10" y="8" width="4" height="12" rx="2"/><rect x="16" y="4" width="4" height="16" rx="2"/></g>`,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
'priority-urgent': {
|
||||
body: `<mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><path fill="#ed8a5c" d="M0 0h24v24H0z"/></mask><g mask="url(#a)" fill="#e5484d"><rect x="4" y="12" width="4" height="8" rx="2"/><rect x="10" y="8" width="4" height="12" rx="2"/><path d="M18 20q-.824 0-1.413-.49Q16 19.021 16 18.333q0-.687.587-1.177.588-.49 1.413-.49.824 0 1.413.49.587.49.587 1.177t-.587 1.177T18 20m0-5q-.824 0-1.413-.49Q16 14.021 16 13.333V6.667q0-.688.587-1.177Q17.176 5 18 5t1.413.49Q20 5.979 20 6.667v6.666q0 .688-.587 1.177Q18.825 15 18 15"/></g>`,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
/** Ends */
|
||||
|
||||
/** Channels Starts */
|
||||
website: {
|
||||
|
||||
Reference in New Issue
Block a user