Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cbc19b3f2 | ||
|
|
65dedba3ab | ||
|
|
7e760732a2 | ||
|
|
51c7e3ff0f | ||
|
|
3b1e3683da | ||
|
|
8a983c10b4 | ||
|
|
1265cb0ff9 | ||
|
|
11b22b2804 | ||
|
|
cc5a76f24a | ||
|
|
f6d11d2fa8 | ||
|
|
e05f024581 | ||
|
|
d791e51f5d | ||
|
|
014167fd62 |
@@ -92,7 +92,14 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
|
params.permit(
|
||||||
|
:auto_resolve_after,
|
||||||
|
:auto_resolve_message,
|
||||||
|
:auto_resolve_ignore_waiting,
|
||||||
|
:audio_transcriptions,
|
||||||
|
:auto_resolve_label,
|
||||||
|
conversation_required_attributes: []
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex justify-between items-center px-4 py-4 w-full">
|
||||||
|
<div
|
||||||
|
class="flex justify-center items-center py-6 w-full custom-dashed-border"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-n-slate-11">
|
||||||
|
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.NO_ATTRIBUTES') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: { type: String, default: 'resolution' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorClass = computed(() => {
|
||||||
|
const colorMap = {
|
||||||
|
'pre-chat': 'blue',
|
||||||
|
resolution: 'teal',
|
||||||
|
};
|
||||||
|
const color = colorMap[props.type] || colorMap.resolution;
|
||||||
|
return `text-n-${color}-11`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconName = computed(() => {
|
||||||
|
const iconMap = {
|
||||||
|
'pre-chat': 'i-lucide-message-circle',
|
||||||
|
resolution: 'i-lucide-circle-check-big',
|
||||||
|
};
|
||||||
|
return iconMap[props.type] || iconMap.resolution;
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayLabel = computed(() => {
|
||||||
|
const labelMap = {
|
||||||
|
'pre-chat': 'Pre-chat',
|
||||||
|
resolution: 'Resolution',
|
||||||
|
};
|
||||||
|
return labelMap[props.type] || labelMap.resolution;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconName" class="size-4" :class="colorClass" />
|
||||||
|
<span class="text-xs" :class="colorClass">{{ displayLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import AttributeBadge from 'dashboard/components-next/ConversationWorkflow/AttributeBadge.vue';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
attribute: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
badges: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['edit', 'delete']);
|
||||||
|
|
||||||
|
const iconByType = {
|
||||||
|
text: 'i-lucide-align-justify',
|
||||||
|
checkbox: 'i-lucide-circle-check-big',
|
||||||
|
list: 'i-lucide-list',
|
||||||
|
date: 'i-lucide-calendar',
|
||||||
|
link: 'i-lucide-link',
|
||||||
|
number: 'i-lucide-hash',
|
||||||
|
};
|
||||||
|
|
||||||
|
const attributeIcon = computed(() => {
|
||||||
|
const typeKey = props.attribute.type?.toLowerCase();
|
||||||
|
return iconByType[typeKey] || 'i-lucide-align-justify';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 p-4 bg-white rounded-2xl border border-n-weak dark:bg-n-solid-1"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-between items-center">
|
||||||
|
<div class="flex flex-wrap gap-2 items-center min-w-0">
|
||||||
|
<h4 class="text-sm font-medium truncate text-n-slate-12">
|
||||||
|
{{ attribute.label }}
|
||||||
|
</h4>
|
||||||
|
<div class="flex gap-2 items-center text-sm text-n-slate-11">
|
||||||
|
<Icon :icon="attributeIcon" class="size-4" />
|
||||||
|
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
|
||||||
|
<div class="w-px h-3 bg-n-weak" />
|
||||||
|
<Icon icon="i-lucide-key-round" class="size-4" />
|
||||||
|
<span class="line-clamp-1">{{ attribute.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<AttributeBadge
|
||||||
|
v-for="badge in badges"
|
||||||
|
:key="badge.label"
|
||||||
|
:type="badge.type"
|
||||||
|
/>
|
||||||
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-pencil-line"
|
||||||
|
size="sm"
|
||||||
|
color="slate"
|
||||||
|
ghost
|
||||||
|
@click="emit('edit', attribute)"
|
||||||
|
/>
|
||||||
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
size="sm"
|
||||||
|
color="slate"
|
||||||
|
ghost
|
||||||
|
@click="emit('delete', attribute)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mb-0 text-sm text-n-slate-11">
|
||||||
|
{{ attribute.attribute_description || attribute.description || '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
attribute: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['delete']);
|
||||||
|
|
||||||
|
const iconByType = {
|
||||||
|
text: 'i-lucide-align-justify',
|
||||||
|
checkbox: 'i-lucide-circle-check-big',
|
||||||
|
list: 'i-lucide-list',
|
||||||
|
date: 'i-lucide-calendar',
|
||||||
|
link: 'i-lucide-link',
|
||||||
|
number: 'i-lucide-hash',
|
||||||
|
};
|
||||||
|
|
||||||
|
const attributeIcon = computed(() => {
|
||||||
|
const typeKey = props.attribute.type?.toLowerCase();
|
||||||
|
return iconByType[typeKey] || 'i-lucide-align-justify';
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
emit('delete', props.attribute);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex justify-between items-center px-4 py-3 w-full">
|
||||||
|
<div class="flex gap-3 items-center">
|
||||||
|
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
||||||
|
{{ attribute.label }}
|
||||||
|
</h5>
|
||||||
|
<div class="w-px h-2.5 bg-n-slate-5" />
|
||||||
|
<div class="flex gap-1.5 items-center">
|
||||||
|
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
|
||||||
|
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-2.5 bg-n-slate-5" />
|
||||||
|
<div class="flex gap-1.5 items-center">
|
||||||
|
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
|
||||||
|
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<Button icon="i-lucide-trash" sm slate ghost @click.stop="handleDelete" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import ConversationRequiredAttributeItem from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributeItem.vue';
|
||||||
|
import ConversationRequiredEmpty from 'dashboard/components-next/Conversation/ConversationRequiredEmpty.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['click']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { currentAccount, updateAccount } = useAccount();
|
||||||
|
const showDropdown = ref(false);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const conversationAttributes = useMapGetter(
|
||||||
|
'attributes/getConversationAttributes'
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
emit('click');
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedAttributeKeys = computed(
|
||||||
|
() => currentAccount.value?.settings?.conversation_required_attributes || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const allAttributeOptions = computed(() =>
|
||||||
|
(conversationAttributes.value || []).map(attribute => ({
|
||||||
|
...attribute,
|
||||||
|
action: 'add',
|
||||||
|
value: attribute.attributeKey,
|
||||||
|
label: attribute.attributeDisplayName,
|
||||||
|
type: attribute.attributeDisplayType,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const attributeOptions = computed(() =>
|
||||||
|
allAttributeOptions.value.filter(
|
||||||
|
attribute => !selectedAttributeKeys.value.includes(attribute.value)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const conversationRequiredAttributes = computed(() =>
|
||||||
|
selectedAttributeKeys.value
|
||||||
|
.map(key =>
|
||||||
|
allAttributeOptions.value.find(attribute => attribute.value === key)
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddAttributesClick = event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
showDropdown.value = !showDropdown.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRequiredAttributes = async keys => {
|
||||||
|
try {
|
||||||
|
isSaving.value = true;
|
||||||
|
await updateAccount(
|
||||||
|
{ conversation_required_attributes: keys },
|
||||||
|
{ silent: true }
|
||||||
|
);
|
||||||
|
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.SUCCESS'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.ERROR'));
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
showDropdown.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttributeAction = ({ value }) => {
|
||||||
|
if (!value || isSaving.value) return;
|
||||||
|
const updatedKeys = Array.from(
|
||||||
|
new Set([...selectedAttributeKeys.value, value])
|
||||||
|
);
|
||||||
|
saveRequiredAttributes(updatedKeys);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDropdown = () => {
|
||||||
|
showDropdown.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = attribute => {
|
||||||
|
if (isSaving.value) return;
|
||||||
|
const updatedKeys = selectedAttributeKeys.value.filter(
|
||||||
|
key => key !== attribute.value
|
||||||
|
);
|
||||||
|
saveRequiredAttributes(updatedKeys);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CardLayout
|
||||||
|
class="[&>div]:px-0 [&>div]:py-0 [&>div]:gap-0 [&>div]:divide-y [&>div]:divide-n-weak"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2 items-start px-5 py-4">
|
||||||
|
<div class="flex justify-between items-center w-full">
|
||||||
|
<h3 class="text-base font-medium text-n-slate-12">{{ title }}</h3>
|
||||||
|
<div v-on-clickaway="closeDropdown" class="relative">
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-circle-plus"
|
||||||
|
:label="$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.TITLE')"
|
||||||
|
:is-loading="isSaving"
|
||||||
|
:disabled="isSaving || attributeOptions.length === 0"
|
||||||
|
@click="handleAddAttributesClick"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showDropdown"
|
||||||
|
:menu-items="attributeOptions"
|
||||||
|
show-search
|
||||||
|
:search-placeholder="
|
||||||
|
$t(
|
||||||
|
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.SEARCH_PLACEHOLDER'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="top-full mt-1 w-52 ltr:right-0 rtl:left-0"
|
||||||
|
@action="handleAttributeAction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mb-0 text-sm text-n-slate-11">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConversationRequiredEmpty
|
||||||
|
v-if="conversationRequiredAttributes.length === 0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConversationRequiredAttributeItem
|
||||||
|
v-for="attribute in conversationRequiredAttributes"
|
||||||
|
:key="attribute.value"
|
||||||
|
:attribute="attribute"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
</CardLayout>
|
||||||
|
</template>
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import TextArea from 'next/textarea/TextArea.vue';
|
||||||
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import ChoiceToggle from 'dashboard/components-next/input/ChoiceToggle.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
const visibleAttributes = ref([]);
|
||||||
|
const formValues = ref({});
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
dialogRef.value?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaceholder = type => {
|
||||||
|
const placeholders = {
|
||||||
|
text: t(
|
||||||
|
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.TEXT'
|
||||||
|
),
|
||||||
|
number: t(
|
||||||
|
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.NUMBER'
|
||||||
|
),
|
||||||
|
link: t(
|
||||||
|
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LINK'
|
||||||
|
),
|
||||||
|
date: t(
|
||||||
|
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.DATE'
|
||||||
|
),
|
||||||
|
list: t(
|
||||||
|
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LIST'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return placeholders[type] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormComplete = computed(() =>
|
||||||
|
visibleAttributes.value.every(attribute => {
|
||||||
|
const value = formValues.value?.[attribute.value];
|
||||||
|
return value !== undefined && value !== null && String(value).trim() !== '';
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const open = (attributes = [], initialValues = {}) => {
|
||||||
|
visibleAttributes.value = attributes;
|
||||||
|
formValues.value = attributes.reduce((acc, attribute) => {
|
||||||
|
const presetValue = initialValues[attribute.value];
|
||||||
|
acc[attribute.value] =
|
||||||
|
presetValue !== undefined && presetValue !== null ? presetValue : '';
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
dialogRef.value?.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
emit('submit', { ...formValues.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ open, close });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
width="lg"
|
||||||
|
:title="t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.TITLE')"
|
||||||
|
:description="
|
||||||
|
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.DESCRIPTION')
|
||||||
|
"
|
||||||
|
:confirm-button-label="
|
||||||
|
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.RESOLVE')
|
||||||
|
"
|
||||||
|
:cancel-button-label="
|
||||||
|
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.CANCEL')
|
||||||
|
"
|
||||||
|
:show-confirm-button="isFormComplete"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
v-for="attribute in visibleAttributes"
|
||||||
|
:key="attribute.value"
|
||||||
|
class="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ attribute.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="attribute.type === 'text'">
|
||||||
|
<TextArea
|
||||||
|
v-model="formValues[attribute.value]"
|
||||||
|
class="w-full"
|
||||||
|
:placeholder="getPlaceholder('text')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="attribute.type === 'number'">
|
||||||
|
<Input
|
||||||
|
v-model="formValues[attribute.value]"
|
||||||
|
type="number"
|
||||||
|
size="md"
|
||||||
|
:placeholder="getPlaceholder('number')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="attribute.type === 'link'">
|
||||||
|
<Input
|
||||||
|
v-model="formValues[attribute.value]"
|
||||||
|
type="url"
|
||||||
|
size="md"
|
||||||
|
:placeholder="getPlaceholder('link')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="attribute.type === 'date'">
|
||||||
|
<Input
|
||||||
|
v-model="formValues[attribute.value]"
|
||||||
|
type="date"
|
||||||
|
size="md"
|
||||||
|
:placeholder="getPlaceholder('date')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="attribute.type === 'list'">
|
||||||
|
<ComboBox
|
||||||
|
v-model="formValues[attribute.value]"
|
||||||
|
:options="
|
||||||
|
(
|
||||||
|
attribute.attribute_values ||
|
||||||
|
attribute.attributeValues ||
|
||||||
|
[]
|
||||||
|
).map(option => ({
|
||||||
|
value: option,
|
||||||
|
label: option,
|
||||||
|
}))
|
||||||
|
"
|
||||||
|
:placeholder="getPlaceholder('list')"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="attribute.type === 'checkbox'">
|
||||||
|
<ChoiceToggle
|
||||||
|
v-model="formValues[attribute.value]"
|
||||||
|
:yes-label="
|
||||||
|
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.CHECKBOX.YES')
|
||||||
|
"
|
||||||
|
:no-label="
|
||||||
|
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.CHECKBOX.NO')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
yesLabel: {
|
||||||
|
type: String,
|
||||||
|
default: 'Yes',
|
||||||
|
},
|
||||||
|
noLabel: {
|
||||||
|
type: String,
|
||||||
|
default: 'No',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
|
const options = computed(() => [
|
||||||
|
{ label: props.yesLabel, value: 'yes' },
|
||||||
|
{ label: props.noLabel, value: 'no' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleSelect = value => {
|
||||||
|
emit('update:modelValue', value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex gap-4 items-center px-4 py-2.5 w-full bg-white rounded-lg divide-x transition-colors outline outline-1 outline-n-weak dark:bg-n-solid-1 hover:outline-n-slate-6 focus-within:outline-n-brand divide-n-weak"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
class="flex flex-1 gap-2 justify-center items-center"
|
||||||
|
>
|
||||||
|
<label class="inline-flex gap-2 items-center text-base cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:value="option.value"
|
||||||
|
:checked="modelValue === option.value"
|
||||||
|
class="size-4 accent-n-blue-9 text-n-blue-9"
|
||||||
|
@change="handleSelect(option.value)"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-n-slate-12">{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -520,7 +520,7 @@ const menuItems = computed(() => {
|
|||||||
{
|
{
|
||||||
name: 'Settings Automation',
|
name: 'Settings Automation',
|
||||||
label: t('SIDEBAR.AUTOMATION'),
|
label: t('SIDEBAR.AUTOMATION'),
|
||||||
icon: 'i-lucide-workflow',
|
icon: 'i-lucide-repeat',
|
||||||
to: accountScopedRoute('automation_list'),
|
to: accountScopedRoute('automation_list'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -565,6 +565,12 @@ const menuItems = computed(() => {
|
|||||||
icon: 'i-lucide-clock-alert',
|
icon: 'i-lucide-clock-alert',
|
||||||
to: accountScopedRoute('sla_list'),
|
to: accountScopedRoute('sla_list'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: ' Conversation Workflow',
|
||||||
|
label: t('SIDEBAR.CONVERSATION_WORKFLOW'),
|
||||||
|
icon: 'i-lucide-workflow',
|
||||||
|
to: accountScopedRoute('conversation_workflow_index'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Settings Security',
|
name: 'Settings Security',
|
||||||
label: t('SIDEBAR.SECURITY'),
|
label: t('SIDEBAR.SECURITY'),
|
||||||
@@ -646,7 +652,7 @@ const menuItems = computed(() => {
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<section
|
<section
|
||||||
class="flex flex-col flex-shrink-0 relative gap-1 justify-between items-center"
|
class="flex relative flex-col flex-shrink-0 gap-1 justify-between items-center"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['tabChanged']);
|
const emit = defineEmits(['change', 'tabChanged']);
|
||||||
|
|
||||||
const activeTab = computed(() => props.initialActiveTab);
|
const activeTab = computed(() => props.initialActiveTab);
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectTab = index => {
|
const selectTab = index => {
|
||||||
|
emit('change', index);
|
||||||
emit('tabChanged', props.tabs[index]);
|
emit('tabChanged', props.tabs[index]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import { ref, computed } from 'vue';
|
|||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useToggle } from '@vueuse/core';
|
import { useToggle } from '@vueuse/core';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
import {
|
||||||
|
useStore,
|
||||||
|
useStoreGetters,
|
||||||
|
useMapGetter,
|
||||||
|
} from 'dashboard/composables/store';
|
||||||
import { useEmitter } from 'dashboard/composables/emitter';
|
import { useEmitter } from 'dashboard/composables/emitter';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
|
||||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||||
@@ -17,13 +22,19 @@ import {
|
|||||||
|
|
||||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const getters = useStoreGetters();
|
const getters = useStoreGetters();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { currentAccount } = useAccount();
|
||||||
|
const conversationAttributes = useMapGetter(
|
||||||
|
'attributes/getConversationAttributes'
|
||||||
|
);
|
||||||
|
|
||||||
const arrowDownButtonRef = ref(null);
|
const arrowDownButtonRef = ref(null);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
const resolveAttributesModalRef = ref(null);
|
||||||
|
|
||||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||||
const closeDropdown = () => toggleDropdown(false);
|
const closeDropdown = () => toggleDropdown(false);
|
||||||
@@ -52,31 +63,103 @@ const showOpenButton = computed(() => {
|
|||||||
return isPending.value || isSnoozed.value;
|
return isPending.value || isSnoozed.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const getConversationParams = () => {
|
|
||||||
const allConversations = document.querySelectorAll(
|
|
||||||
'.conversations-list .conversation'
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeConversation = document.querySelector(
|
|
||||||
'div.conversations-list div.conversation.active'
|
|
||||||
);
|
|
||||||
const activeConversationIndex = [...allConversations].indexOf(
|
|
||||||
activeConversation
|
|
||||||
);
|
|
||||||
const lastConversationIndex = allConversations.length - 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
all: allConversations,
|
|
||||||
activeIndex: activeConversationIndex,
|
|
||||||
lastIndex: lastConversationIndex,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const openSnoozeModal = () => {
|
const openSnoozeModal = () => {
|
||||||
const ninja = document.querySelector('ninja-keys');
|
const ninja = document.querySelector('ninja-keys');
|
||||||
ninja.open({ parent: 'snooze_conversation' });
|
ninja.open({ parent: 'snooze_conversation' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requiredAttributeKeys = computed(
|
||||||
|
() => currentAccount.value?.settings?.conversation_required_attributes || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const requiredAttributes = computed(() =>
|
||||||
|
requiredAttributeKeys.value.map(key => {
|
||||||
|
const definition = conversationAttributes.value?.find(
|
||||||
|
attribute => attribute.attributeKey === key
|
||||||
|
);
|
||||||
|
if (!definition) {
|
||||||
|
return {
|
||||||
|
value: key,
|
||||||
|
label: key,
|
||||||
|
type: 'text',
|
||||||
|
attribute_values: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
value: definition.attributeKey,
|
||||||
|
label: definition.attributeDisplayName,
|
||||||
|
type: definition.attributeDisplayType,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const getMissingRequiredAttributes = () => {
|
||||||
|
if (!currentChat.value?.id) return [];
|
||||||
|
const existingValues = currentChat.value.custom_attributes || {};
|
||||||
|
return requiredAttributes.value.filter(attribute => {
|
||||||
|
const value = existingValues?.[attribute.value];
|
||||||
|
return value === undefined || value === null || String(value) === '';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStatusInternal = async (status, snoozedUntil = null) => {
|
||||||
|
closeDropdown();
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
await store.dispatch('toggleStatus', {
|
||||||
|
conversationId: currentChat.value.id,
|
||||||
|
status,
|
||||||
|
snoozedUntil,
|
||||||
|
});
|
||||||
|
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveWithAttributes = async customAttributes => {
|
||||||
|
if (!currentChat.value?.id) return;
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
if (requiredAttributeKeys.value.length) {
|
||||||
|
await store.dispatch('updateCustomAttributes', {
|
||||||
|
conversationId: currentChat.value.id,
|
||||||
|
customAttributes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await toggleStatusInternal(wootConstants.STATUS_TYPE.RESOLVED);
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.ERROR'));
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openResolveModal = () => {
|
||||||
|
closeDropdown();
|
||||||
|
if (!currentChat.value?.id) return;
|
||||||
|
const missing = getMissingRequiredAttributes();
|
||||||
|
if (!missing.length) {
|
||||||
|
resolveWithAttributes(currentChat.value.custom_attributes || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolveAttributesModalRef.value?.open(
|
||||||
|
missing,
|
||||||
|
currentChat.value.custom_attributes || {}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalSubmit = async values => {
|
||||||
|
if (!currentChat.value?.id) return;
|
||||||
|
resolveAttributesModalRef.value?.close();
|
||||||
|
await resolveWithAttributes({
|
||||||
|
...(currentChat.value.custom_attributes || {}),
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const toggleStatus = (status, snoozedUntil) => {
|
const toggleStatus = (status, snoozedUntil) => {
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
@@ -97,7 +180,7 @@ const onCmdOpenConversation = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onCmdResolveConversation = () => {
|
const onCmdResolveConversation = () => {
|
||||||
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
openResolveModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const keyboardEvents = {
|
const keyboardEvents = {
|
||||||
@@ -106,21 +189,11 @@ const keyboardEvents = {
|
|||||||
allowOnFocusedInput: true,
|
allowOnFocusedInput: true,
|
||||||
},
|
},
|
||||||
'Alt+KeyE': {
|
'Alt+KeyE': {
|
||||||
action: async () => {
|
action: () => openResolveModal(),
|
||||||
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'$mod+Alt+KeyE': {
|
'$mod+Alt+KeyE': {
|
||||||
action: async event => {
|
action: event => {
|
||||||
const { all, activeIndex, lastIndex } = getConversationParams();
|
openResolveModal();
|
||||||
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
|
||||||
|
|
||||||
if (activeIndex < lastIndex) {
|
|
||||||
all[activeIndex + 1].click();
|
|
||||||
} else if (all.length > 1) {
|
|
||||||
all[0].click();
|
|
||||||
document.querySelector('.conversations-list').scrollTop = 0;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -133,9 +206,13 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative flex items-center justify-end resolve-actions">
|
<div class="flex relative justify-end items-center resolve-actions">
|
||||||
|
<ConversationResolveAttributesModal
|
||||||
|
ref="resolveAttributesModalRef"
|
||||||
|
@submit="handleModalSubmit"
|
||||||
|
/>
|
||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
class="rounded-lg shadow outline-1 outline flex-shrink-0"
|
class="flex-shrink-0 rounded-lg shadow outline-1 outline"
|
||||||
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
|
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,18 +1,43 @@
|
|||||||
import { ref, unref } from 'vue';
|
import { computed, ref, unref } from 'vue';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
|
||||||
export function useBulkActions() {
|
export function useBulkActions() {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { currentAccount } = useAccount();
|
||||||
|
|
||||||
const selectedConversations = useMapGetter(
|
const selectedConversations = useMapGetter(
|
||||||
'bulkActions/getSelectedConversationIds'
|
'bulkActions/getSelectedConversationIds'
|
||||||
);
|
);
|
||||||
|
const allConversations = useMapGetter('getAllConversations');
|
||||||
const selectedInboxes = ref([]);
|
const selectedInboxes = ref([]);
|
||||||
|
|
||||||
|
const requiredAttributeKeys = computed(
|
||||||
|
() => currentAccount.value?.settings?.conversation_required_attributes || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const conversationsById = computed(() =>
|
||||||
|
(allConversations.value || []).reduce((acc, conversation) => {
|
||||||
|
acc[conversation.id] = conversation;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAllRequiredValues = conversation => {
|
||||||
|
if (!conversation) return false;
|
||||||
|
const attrs = conversation.custom_attributes || {};
|
||||||
|
return requiredAttributeKeys.value.every(key => {
|
||||||
|
const value = attrs?.[key];
|
||||||
|
return (
|
||||||
|
value !== undefined && value !== null && String(value).trim() !== ''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function selectConversation(conversationId, inboxId) {
|
function selectConversation(conversationId, inboxId) {
|
||||||
store.dispatch('bulkActions/setSelectedConversationIds', conversationId);
|
store.dispatch('bulkActions/setSelectedConversationIds', conversationId);
|
||||||
selectedInboxes.value = [...selectedInboxes.value, inboxId];
|
selectedInboxes.value = [...selectedInboxes.value, inboxId];
|
||||||
@@ -116,17 +141,58 @@ export function useBulkActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onUpdateConversations(status, snoozedUntil) {
|
async function onUpdateConversations(status, snoozedUntil) {
|
||||||
|
const selectedIds = selectedConversations.value;
|
||||||
|
const requiresCheck =
|
||||||
|
status === 'resolved' && requiredAttributeKeys.value.length > 0;
|
||||||
|
|
||||||
|
const resolveCandidates = requiresCheck
|
||||||
|
? selectedIds.reduce(
|
||||||
|
(result, id) => {
|
||||||
|
const conversation = conversationsById.value[id];
|
||||||
|
if (hasAllRequiredValues(conversation)) {
|
||||||
|
result.resolvable.push(id);
|
||||||
|
} else {
|
||||||
|
result.blocked.push(id);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{ resolvable: [], blocked: [] }
|
||||||
|
)
|
||||||
|
: { resolvable: selectedIds, blocked: [] };
|
||||||
|
|
||||||
|
if (!resolveCandidates.resolvable.length) {
|
||||||
|
if (requiresCheck) {
|
||||||
|
useAlert(t('BULK_ACTION.UPDATE.REQUIRED_ATTRIBUTES_MISSING'));
|
||||||
|
} else {
|
||||||
|
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.dispatch('bulkActions/process', {
|
await store.dispatch('bulkActions/process', {
|
||||||
type: 'Conversation',
|
type: 'Conversation',
|
||||||
ids: selectedConversations.value,
|
ids: resolveCandidates.resolvable,
|
||||||
fields: {
|
fields: {
|
||||||
status,
|
status,
|
||||||
},
|
},
|
||||||
snoozed_until: snoozedUntil,
|
snoozed_until: snoozedUntil,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update selection to keep only blocked conversations, if any.
|
||||||
store.dispatch('bulkActions/clearSelectedConversationIds');
|
store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
selectedInboxes.value = [];
|
||||||
|
if (resolveCandidates.blocked.length) {
|
||||||
|
store.dispatch('bulkActions/setSelectedConversationIds', [
|
||||||
|
...resolveCandidates.blocked,
|
||||||
|
]);
|
||||||
|
selectedInboxes.value = resolveCandidates.blocked
|
||||||
|
.map(id => conversationsById.value[id]?.inbox_id)
|
||||||
|
.filter(Boolean);
|
||||||
|
useAlert(t('BULK_ACTION.UPDATE.REQUIRED_ATTRIBUTES_MISSING'));
|
||||||
|
} else {
|
||||||
|
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
"CHANGE_STATUS": "Change status",
|
"CHANGE_STATUS": "Change status",
|
||||||
"SNOOZE_UNTIL": "Snooze",
|
"SNOOZE_UNTIL": "Snooze",
|
||||||
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
|
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
|
||||||
"UPDATE_FAILED": "Failed to update conversations. Please try again."
|
"UPDATE_FAILED": "Failed to update conversations. Please try again.",
|
||||||
|
"REQUIRED_ATTRIBUTES_MISSING": "Some conversations need required attributes before resolving and were skipped."
|
||||||
},
|
},
|
||||||
"LABELS": {
|
"LABELS": {
|
||||||
"ASSIGN_LABELS": "Assign labels",
|
"ASSIGN_LABELS": "Assign labels",
|
||||||
|
|||||||
@@ -377,7 +377,8 @@
|
|||||||
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
||||||
},
|
},
|
||||||
"DOCS": "Read docs",
|
"DOCS": "Read docs",
|
||||||
"SECURITY": "Security"
|
"SECURITY": "Security",
|
||||||
|
"CONVERSATION_WORKFLOW": "Conversation Workflow"
|
||||||
},
|
},
|
||||||
"BILLING_SETTINGS": {
|
"BILLING_SETTINGS": {
|
||||||
"TITLE": "Billing",
|
"TITLE": "Billing",
|
||||||
@@ -481,6 +482,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CONVERSATION_WORKFLOW": {
|
||||||
|
"INDEX": {
|
||||||
|
"HEADER": {
|
||||||
|
"TITLE": "Conversation Workflows",
|
||||||
|
"DESCRIPTION": "Configure rules and required fields for conversation resolution."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"REQUIRED_ATTRIBUTES": {
|
||||||
|
"TITLE": "Attributes required on resolution",
|
||||||
|
"DESCRIPTION": "When resolving a conversation, agents will be prompted to fill these attributes if they haven't yet.",
|
||||||
|
"NO_ATTRIBUTES": "No attributes added yet",
|
||||||
|
"ADD": {
|
||||||
|
"TITLE": "Add Attributes",
|
||||||
|
"SEARCH_PLACEHOLDER": "Search attributes"
|
||||||
|
},
|
||||||
|
"SAVE": {
|
||||||
|
"SUCCESS": "Required attributes updated",
|
||||||
|
"ERROR": "Could not update required attributes, please try again"
|
||||||
|
},
|
||||||
|
"MODAL": {
|
||||||
|
"TITLE": "Resolve conversation",
|
||||||
|
"DESCRIPTION": "Please fill in the following custom attributes before resolving this conversation",
|
||||||
|
"ACTIONS": {
|
||||||
|
"RESOLVE": "Resolve conversation",
|
||||||
|
"CANCEL": "Cancel"
|
||||||
|
},
|
||||||
|
"PLACEHOLDERS": {
|
||||||
|
"TEXT": "Write a note...",
|
||||||
|
"NUMBER": "Enter a number",
|
||||||
|
"LINK": "Add a link",
|
||||||
|
"DATE": "Pick a date",
|
||||||
|
"LIST": "Select an option"
|
||||||
|
},
|
||||||
|
"CHECKBOX": {
|
||||||
|
"YES": "Yes",
|
||||||
|
"NO": "No"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"CREATE_ACCOUNT": {
|
"CREATE_ACCOUNT": {
|
||||||
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
|
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
|
||||||
"NEW_ACCOUNT": "New Account",
|
"NEW_ACCOUNT": "New Account",
|
||||||
|
|||||||
@@ -2,20 +2,32 @@
|
|||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||||
import AddAttribute from './AddAttribute.vue';
|
import AddAttribute from './AddAttribute.vue';
|
||||||
import CustomAttribute from './CustomAttribute.vue';
|
import EditAttribute from './EditAttribute.vue';
|
||||||
import SettingsLayout from '../SettingsLayout.vue';
|
import SettingsLayout from '../SettingsLayout.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||||
|
import AttributeListItem from 'dashboard/components-next/ConversationWorkflow/AttributeListItem.vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
import {
|
||||||
|
useStoreGetters,
|
||||||
|
useStore,
|
||||||
|
useMapGetter,
|
||||||
|
} from 'dashboard/composables/store';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const getters = useStoreGetters();
|
const getters = useStoreGetters();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
const { currentAccount } = useAccount();
|
||||||
|
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||||
|
|
||||||
const showAddPopup = ref(false);
|
const showAddPopup = ref(false);
|
||||||
const selectedTabIndex = ref(0);
|
const selectedTabIndex = ref(0);
|
||||||
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
|
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
|
||||||
|
const showEditPopup = ref(false);
|
||||||
|
const showDeletePopup = ref(false);
|
||||||
|
const selectedAttribute = ref({});
|
||||||
|
|
||||||
const openAddPopup = () => {
|
const openAddPopup = () => {
|
||||||
showAddPopup.value = true;
|
showAddPopup.value = true;
|
||||||
@@ -23,6 +35,14 @@ const openAddPopup = () => {
|
|||||||
const hideAddPopup = () => {
|
const hideAddPopup = () => {
|
||||||
showAddPopup.value = false;
|
showAddPopup.value = false;
|
||||||
};
|
};
|
||||||
|
const hideEditPopup = () => {
|
||||||
|
showEditPopup.value = false;
|
||||||
|
selectedAttribute.value = {};
|
||||||
|
};
|
||||||
|
const closeDelete = () => {
|
||||||
|
showDeletePopup.value = false;
|
||||||
|
selectedAttribute.value = {};
|
||||||
|
};
|
||||||
|
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
return [
|
return [
|
||||||
@@ -49,9 +69,63 @@ const attributes = computed(() =>
|
|||||||
getters['attributes/getAttributesByModel'].value(attributeModel.value)
|
getters['attributes/getAttributesByModel'].value(attributeModel.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
const onClickTabChange = index => {
|
const onClickTabChange = payload => {
|
||||||
selectedTabIndex.value = index;
|
selectedTabIndex.value = typeof payload === 'number' ? payload : payload?.key;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditAttribute = attribute => {
|
||||||
|
selectedAttribute.value = attribute;
|
||||||
|
showEditPopup.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAttribute = attribute => {
|
||||||
|
selectedAttribute.value = attribute;
|
||||||
|
showDeletePopup.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requiredAttributeKeys = computed(
|
||||||
|
() => currentAccount.value?.settings?.conversation_required_attributes || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasPreChatBadge = attribute => {
|
||||||
|
return (inboxes.value || []).some(inbox => {
|
||||||
|
const fields =
|
||||||
|
inbox?.pre_chat_form_options?.pre_chat_fields ||
|
||||||
|
inbox?.channel?.pre_chat_form_options?.pre_chat_fields ||
|
||||||
|
[];
|
||||||
|
return fields.some(field => field.name === attribute.attribute_key);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildBadges = attribute => {
|
||||||
|
const badges = [];
|
||||||
|
if (hasPreChatBadge(attribute)) {
|
||||||
|
badges.push({
|
||||||
|
type: 'pre-chat',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
attribute.attribute_model === 'conversation_attribute' &&
|
||||||
|
requiredAttributeKeys.value.includes(attribute.attribute_key)
|
||||||
|
) {
|
||||||
|
badges.push({
|
||||||
|
type: 'resolution',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
};
|
||||||
|
|
||||||
|
const derivedAttributes = computed(() =>
|
||||||
|
attributes.value.map(attribute => ({
|
||||||
|
...attribute,
|
||||||
|
label: attribute.attribute_display_name,
|
||||||
|
type: attribute.attribute_display_type,
|
||||||
|
value: attribute.attribute_key,
|
||||||
|
badges: buildBadges(attribute),
|
||||||
|
}))
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -77,27 +151,25 @@ const onClickTabChange = index => {
|
|||||||
</template>
|
</template>
|
||||||
</BaseSettingsHeader>
|
</BaseSettingsHeader>
|
||||||
</template>
|
</template>
|
||||||
<template #preBody>
|
|
||||||
<woot-tabs
|
|
||||||
class="font-medium [&_ul]:p-0 mb-4"
|
|
||||||
:index="selectedTabIndex"
|
|
||||||
@change="onClickTabChange"
|
|
||||||
>
|
|
||||||
<woot-tabs-item
|
|
||||||
v-for="(tab, index) in tabs"
|
|
||||||
:key="tab.key"
|
|
||||||
:index="index"
|
|
||||||
:name="tab.name"
|
|
||||||
:show-badge="false"
|
|
||||||
is-compact
|
|
||||||
/>
|
|
||||||
</woot-tabs>
|
|
||||||
</template>
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<CustomAttribute
|
<div class="flex flex-col gap-6">
|
||||||
:key="attributeModel"
|
<TabBar
|
||||||
:attribute-model="attributeModel"
|
:tabs="tabs.map(tab => ({ label: tab.name, key: tab.key }))"
|
||||||
/>
|
:initial-active-tab="selectedTabIndex"
|
||||||
|
class="max-w-xl"
|
||||||
|
@change="onClickTabChange"
|
||||||
|
/>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<AttributeListItem
|
||||||
|
v-for="attribute in derivedAttributes"
|
||||||
|
:key="attribute.id"
|
||||||
|
:attribute="attribute"
|
||||||
|
:badges="attribute.badges"
|
||||||
|
@edit="handleEditAttribute"
|
||||||
|
@delete="handleDeleteAttribute"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<AddAttribute
|
<AddAttribute
|
||||||
v-if="showAddPopup"
|
v-if="showAddPopup"
|
||||||
@@ -105,5 +177,39 @@ const onClickTabChange = index => {
|
|||||||
:on-close="hideAddPopup"
|
:on-close="hideAddPopup"
|
||||||
:selected-attribute-model-tab="selectedTabIndex"
|
:selected-attribute-model-tab="selectedTabIndex"
|
||||||
/>
|
/>
|
||||||
|
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
|
||||||
|
<EditAttribute
|
||||||
|
:selected-attribute="selectedAttribute"
|
||||||
|
:is-updating="uiFlags.isUpdating"
|
||||||
|
@on-close="hideEditPopup"
|
||||||
|
/>
|
||||||
|
</woot-modal>
|
||||||
|
<woot-confirm-delete-modal
|
||||||
|
v-if="showDeletePopup"
|
||||||
|
v-model:show="showDeletePopup"
|
||||||
|
:title="
|
||||||
|
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
|
||||||
|
attributeName: selectedAttribute.attribute_display_name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:message="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.MESSAGE')"
|
||||||
|
:confirm-text="`${$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${
|
||||||
|
selectedAttribute.attribute_display_name || ''
|
||||||
|
}`"
|
||||||
|
:reject-text="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO')"
|
||||||
|
:confirm-value="selectedAttribute.attribute_display_name"
|
||||||
|
:confirm-place-holder-text="
|
||||||
|
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
|
||||||
|
attributeName: selectedAttribute.attribute_display_name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@on-confirm="
|
||||||
|
() => {
|
||||||
|
store.dispatch('attributes/delete', selectedAttribute.id);
|
||||||
|
closeDelete();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@on-close="closeDelete"
|
||||||
|
/>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { frontendURL } from '../../../../helper/URLHelper';
|
||||||
|
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||||
|
import ConversationWorkflowIndex from './Index.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: frontendURL('accounts/:accountId/settings/conversation-workflow'),
|
||||||
|
component: SettingsWrapper,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: to => {
|
||||||
|
return { name: 'conversation_workflow_index', params: to.params };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'index',
|
||||||
|
name: 'conversation_workflow_index',
|
||||||
|
component: ConversationWorkflowIndex,
|
||||||
|
meta: {
|
||||||
|
permissions: ['administrator'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup>
|
||||||
|
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||||
|
import SettingsLayout from '../SettingsLayout.vue';
|
||||||
|
import ConversationRequiredAttributes from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributes.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SettingsLayout :no-records-found="false" class="gap-10">
|
||||||
|
<template #header>
|
||||||
|
<BaseSettingsHeader
|
||||||
|
:title="$t('CONVERSATION_WORKFLOW.INDEX.HEADER.TITLE')"
|
||||||
|
:description="$t('CONVERSATION_WORKFLOW.INDEX.HEADER.DESCRIPTION')"
|
||||||
|
feature-name="assignment-policy"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="flex">
|
||||||
|
<ConversationRequiredAttributes
|
||||||
|
:title="$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE')"
|
||||||
|
:description="
|
||||||
|
$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SettingsLayout>
|
||||||
|
</template>
|
||||||
@@ -24,6 +24,7 @@ import teams from './teams/teams.routes';
|
|||||||
import customRoles from './customRoles/customRole.routes';
|
import customRoles from './customRoles/customRole.routes';
|
||||||
import profile from './profile/profile.routes';
|
import profile from './profile/profile.routes';
|
||||||
import security from './security/security.routes';
|
import security from './security/security.routes';
|
||||||
|
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
routes: [
|
routes: [
|
||||||
@@ -63,5 +64,6 @@ export default {
|
|||||||
...customRoles.routes,
|
...customRoles.routes,
|
||||||
...profile.routes,
|
...profile.routes,
|
||||||
...security.routes,
|
...security.routes,
|
||||||
|
...conversationWorkflow.routes,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ class Account < ApplicationRecord
|
|||||||
'auto_resolve_message': { 'type': %w[string null] },
|
'auto_resolve_message': { 'type': %w[string null] },
|
||||||
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
|
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
|
||||||
'audio_transcriptions': { 'type': %w[boolean null] },
|
'audio_transcriptions': { 'type': %w[boolean null] },
|
||||||
'auto_resolve_label': { 'type': %w[string null] }
|
'auto_resolve_label': { 'type': %w[string null] },
|
||||||
|
'conversation_required_attributes': {
|
||||||
|
'type': %w[array null],
|
||||||
|
'items': { 'type': 'string' }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
'required': [],
|
'required': [],
|
||||||
'additionalProperties': true
|
'additionalProperties': true
|
||||||
@@ -55,7 +59,7 @@ class Account < ApplicationRecord
|
|||||||
attribute_resolver: ->(record) { record.settings }
|
attribute_resolver: ->(record) { record.settings }
|
||||||
|
|
||||||
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
|
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
|
||||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
|
||||||
|
|
||||||
has_many :account_users, dependent: :destroy_async
|
has_many :account_users, dependent: :destroy_async
|
||||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||||
|
|||||||
Reference in New Issue
Block a user