Compare 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
|
||||
|
||||
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
|
||||
|
||||
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',
|
||||
label: t('SIDEBAR.AUTOMATION'),
|
||||
icon: 'i-lucide-workflow',
|
||||
icon: 'i-lucide-repeat',
|
||||
to: accountScopedRoute('automation_list'),
|
||||
},
|
||||
{
|
||||
@@ -565,6 +565,12 @@ const menuItems = computed(() => {
|
||||
icon: 'i-lucide-clock-alert',
|
||||
to: accountScopedRoute('sla_list'),
|
||||
},
|
||||
{
|
||||
name: ' Conversation Workflow',
|
||||
label: t('SIDEBAR.CONVERSATION_WORKFLOW'),
|
||||
icon: 'i-lucide-workflow',
|
||||
to: accountScopedRoute('conversation_workflow_index'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Security',
|
||||
label: t('SIDEBAR.SECURITY'),
|
||||
@@ -646,7 +652,7 @@ const menuItems = computed(() => {
|
||||
</ul>
|
||||
</nav>
|
||||
<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
|
||||
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);
|
||||
|
||||
@@ -51,6 +51,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
const selectTab = index => {
|
||||
emit('change', index);
|
||||
emit('tabChanged', props.tabs[index]);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,9 +3,14 @@ import { ref, computed } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
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 { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.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 Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const { currentAccount } = useAccount();
|
||||
const conversationAttributes = useMapGetter(
|
||||
'attributes/getConversationAttributes'
|
||||
);
|
||||
|
||||
const arrowDownButtonRef = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
const closeDropdown = () => toggleDropdown(false);
|
||||
@@ -52,31 +63,103 @@ const showOpenButton = computed(() => {
|
||||
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 ninja = document.querySelector('ninja-keys');
|
||||
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) => {
|
||||
closeDropdown();
|
||||
isLoading.value = true;
|
||||
@@ -97,7 +180,7 @@ const onCmdOpenConversation = () => {
|
||||
};
|
||||
|
||||
const onCmdResolveConversation = () => {
|
||||
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
openResolveModal();
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
@@ -106,21 +189,11 @@ const keyboardEvents = {
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyE': {
|
||||
action: async () => {
|
||||
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
},
|
||||
action: () => openResolveModal(),
|
||||
},
|
||||
'$mod+Alt+KeyE': {
|
||||
action: async event => {
|
||||
const { all, activeIndex, lastIndex } = getConversationParams();
|
||||
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;
|
||||
}
|
||||
action: event => {
|
||||
openResolveModal();
|
||||
event.preventDefault();
|
||||
},
|
||||
},
|
||||
@@ -133,9 +206,13 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
</script>
|
||||
|
||||
<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
|
||||
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'"
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
import { ref, unref } from 'vue';
|
||||
import { computed, ref, unref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
export function useBulkActions() {
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { currentAccount } = useAccount();
|
||||
|
||||
const selectedConversations = useMapGetter(
|
||||
'bulkActions/getSelectedConversationIds'
|
||||
);
|
||||
const allConversations = useMapGetter('getAllConversations');
|
||||
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) {
|
||||
store.dispatch('bulkActions/setSelectedConversationIds', conversationId);
|
||||
selectedInboxes.value = [...selectedInboxes.value, inboxId];
|
||||
@@ -116,17 +141,58 @@ export function useBulkActions() {
|
||||
}
|
||||
|
||||
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 {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: selectedConversations.value,
|
||||
ids: resolveCandidates.resolvable,
|
||||
fields: {
|
||||
status,
|
||||
},
|
||||
snoozed_until: snoozedUntil,
|
||||
});
|
||||
|
||||
// Update selection to keep only blocked conversations, if any.
|
||||
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) {
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"CHANGE_STATUS": "Change status",
|
||||
"SNOOZE_UNTIL": "Snooze",
|
||||
"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": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
|
||||
@@ -377,7 +377,8 @@
|
||||
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
||||
},
|
||||
"DOCS": "Read docs",
|
||||
"SECURITY": "Security"
|
||||
"SECURITY": "Security",
|
||||
"CONVERSATION_WORKFLOW": "Conversation Workflow"
|
||||
},
|
||||
"BILLING_SETTINGS": {
|
||||
"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": {
|
||||
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
|
||||
"NEW_ACCOUNT": "New Account",
|
||||
|
||||
@@ -2,20 +2,32 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import AddAttribute from './AddAttribute.vue';
|
||||
import CustomAttribute from './CustomAttribute.vue';
|
||||
import EditAttribute from './EditAttribute.vue';
|
||||
import SettingsLayout from '../SettingsLayout.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 { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||
import {
|
||||
useStoreGetters,
|
||||
useStore,
|
||||
useMapGetter,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const store = useStore();
|
||||
const { currentAccount } = useAccount();
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
const showAddPopup = ref(false);
|
||||
const selectedTabIndex = ref(0);
|
||||
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
|
||||
const showEditPopup = ref(false);
|
||||
const showDeletePopup = ref(false);
|
||||
const selectedAttribute = ref({});
|
||||
|
||||
const openAddPopup = () => {
|
||||
showAddPopup.value = true;
|
||||
@@ -23,6 +35,14 @@ const openAddPopup = () => {
|
||||
const hideAddPopup = () => {
|
||||
showAddPopup.value = false;
|
||||
};
|
||||
const hideEditPopup = () => {
|
||||
showEditPopup.value = false;
|
||||
selectedAttribute.value = {};
|
||||
};
|
||||
const closeDelete = () => {
|
||||
showDeletePopup.value = false;
|
||||
selectedAttribute.value = {};
|
||||
};
|
||||
|
||||
const tabs = computed(() => {
|
||||
return [
|
||||
@@ -49,9 +69,63 @@ const attributes = computed(() =>
|
||||
getters['attributes/getAttributesByModel'].value(attributeModel.value)
|
||||
);
|
||||
|
||||
const onClickTabChange = index => {
|
||||
selectedTabIndex.value = index;
|
||||
const onClickTabChange = payload => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -77,27 +151,25 @@ const onClickTabChange = index => {
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</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>
|
||||
<CustomAttribute
|
||||
:key="attributeModel"
|
||||
:attribute-model="attributeModel"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<TabBar
|
||||
: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>
|
||||
<AddAttribute
|
||||
v-if="showAddPopup"
|
||||
@@ -105,5 +177,39 @@ const onClickTabChange = index => {
|
||||
:on-close="hideAddPopup"
|
||||
: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>
|
||||
</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 profile from './profile/profile.routes';
|
||||
import security from './security/security.routes';
|
||||
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -63,5 +64,6 @@ export default {
|
||||
...customRoles.routes,
|
||||
...profile.routes,
|
||||
...security.routes,
|
||||
...conversationWorkflow.routes,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -37,7 +37,11 @@ class Account < ApplicationRecord
|
||||
'auto_resolve_message': { 'type': %w[string null] },
|
||||
'auto_resolve_ignore_waiting': { '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': [],
|
||||
'additionalProperties': true
|
||||
@@ -55,7 +59,7 @@ class Account < ApplicationRecord
|
||||
attribute_resolver: ->(record) { record.settings }
|
||||
|
||||
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 :agent_bot_inboxes, dependent: :destroy_async
|
||||
|
||||
Reference in New Issue
Block a user