feat: auto resolve label option and fixes (#11541)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -92,7 +92,7 @@ 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)
|
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label)
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const updateSelected = newValue => {
|
|||||||
<slot name="trigger" :toggle="toggle">
|
<slot name="trigger" :toggle="toggle">
|
||||||
<Button
|
<Button
|
||||||
ref="triggerRef"
|
ref="triggerRef"
|
||||||
|
type="button"
|
||||||
sm
|
sm
|
||||||
slate
|
slate
|
||||||
:variant
|
:variant
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
|||||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||||
|
|
||||||
const { options } = defineProps({
|
const {
|
||||||
|
options,
|
||||||
|
disableSearch,
|
||||||
|
placeholderIcon,
|
||||||
|
placeholder,
|
||||||
|
placeholderTrailingIcon,
|
||||||
|
searchPlaceholder,
|
||||||
|
} = defineProps({
|
||||||
options: {
|
options: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -18,6 +25,22 @@ const { options } = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
placeholderIcon: {
|
||||||
|
type: String,
|
||||||
|
default: 'i-lucide-plus',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
placeholderTrailingIcon: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
searchPlaceholder: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -69,15 +92,26 @@ const toggleSelected = option => {
|
|||||||
sm
|
sm
|
||||||
slate
|
slate
|
||||||
faded
|
faded
|
||||||
|
type="button"
|
||||||
:icon="selectedItem.icon"
|
:icon="selectedItem.icon"
|
||||||
:label="selectedItem.name"
|
:label="selectedItem.name"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
/>
|
/>
|
||||||
<Button v-else sm slate faded @click="toggle">
|
<Button
|
||||||
|
v-else
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
type="button"
|
||||||
|
:trailing-icon="placeholderTrailingIcon"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
|
<Icon :icon="placeholderIcon" class="text-n-slate-11" />
|
||||||
</template>
|
</template>
|
||||||
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
|
<span class="text-n-slate-11">{{
|
||||||
|
placeholder || t('COMBOBOX.PLACEHOLDER')
|
||||||
|
}}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<DropdownBody class="top-0 min-w-56 z-50" strong>
|
<DropdownBody class="top-0 min-w-56 z-50" strong>
|
||||||
@@ -87,7 +121,7 @@ const toggleSelected = option => {
|
|||||||
v-model="searchTerm"
|
v-model="searchTerm"
|
||||||
autofocus
|
autofocus
|
||||||
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
|
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
|
||||||
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DropdownSection class="max-h-80 overflow-scroll">
|
<DropdownSection class="max-h-80 overflow-scroll">
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import Input from './Input.vue';
|
import Input from './Input.vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { DURATION_UNITS } from './constants';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
min: { type: Number, default: 0 },
|
min: { type: Number, default: 0 },
|
||||||
@@ -11,36 +12,50 @@ const props = defineProps({
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const duration = defineModel('modelValue', { type: Number, default: null });
|
const duration = defineModel('modelValue', { type: Number, default: null });
|
||||||
|
const unit = defineModel('unit', {
|
||||||
|
type: String,
|
||||||
|
default: DURATION_UNITS.MINUTES,
|
||||||
|
validate(value) {
|
||||||
|
return Object.values(DURATION_UNITS).includes(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const UNIT_TYPES = {
|
const convertToMinutes = newValue => {
|
||||||
MINUTES: 'minutes',
|
if (unit.value === DURATION_UNITS.MINUTES) {
|
||||||
HOURS: 'hours',
|
return Math.floor(newValue);
|
||||||
DAYS: 'days',
|
}
|
||||||
|
if (unit.value === DURATION_UNITS.HOURS) {
|
||||||
|
return Math.floor(newValue) * 60;
|
||||||
|
}
|
||||||
|
return Math.floor(newValue) * 24 * 60;
|
||||||
};
|
};
|
||||||
const unit = ref(UNIT_TYPES.MINUTES);
|
|
||||||
|
|
||||||
const transformedValue = computed({
|
const transformedValue = computed({
|
||||||
get() {
|
get() {
|
||||||
if (unit.value === UNIT_TYPES.MINUTES) return duration.value;
|
if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
|
||||||
if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60);
|
if (unit.value === DURATION_UNITS.HOURS)
|
||||||
if (unit.value === UNIT_TYPES.DAYS)
|
return Math.floor(duration.value / 60);
|
||||||
|
if (unit.value === DURATION_UNITS.DAYS)
|
||||||
return Math.floor(duration.value / 24 / 60);
|
return Math.floor(duration.value / 24 / 60);
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
},
|
},
|
||||||
set(newValue) {
|
set(newValue) {
|
||||||
let minuteValue;
|
let minuteValue = convertToMinutes(newValue);
|
||||||
if (unit.value === UNIT_TYPES.MINUTES) {
|
|
||||||
minuteValue = Math.floor(newValue);
|
|
||||||
} else if (unit.value === UNIT_TYPES.HOURS) {
|
|
||||||
minuteValue = Math.floor(newValue * 60);
|
|
||||||
} else if (unit.value === UNIT_TYPES.DAYS) {
|
|
||||||
minuteValue = Math.floor(newValue * 24 * 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
|
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// when unit is changed set the nearest value to that unit
|
||||||
|
// so if the minute is set to 900, and the user changes the unit to "days"
|
||||||
|
// the transformed value will show 0, but the real value will still be 900
|
||||||
|
// this might create some confusion, especially when saving
|
||||||
|
// this watcher fixes it by rounding the duration basically, to the nearest unit value
|
||||||
|
watch(unit, () => {
|
||||||
|
let adjustedValue = convertToMinutes(transformedValue.value);
|
||||||
|
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -57,10 +72,12 @@ const transformedValue = computed({
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
|
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<option :value="UNIT_TYPES.MINUTES">
|
<option :value="DURATION_UNITS.MINUTES">
|
||||||
{{ t('DURATION_INPUT.MINUTES') }}
|
{{ t('DURATION_INPUT.MINUTES') }}
|
||||||
</option>
|
</option>
|
||||||
<option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option>
|
<option :value="DURATION_UNITS.HOURS">
|
||||||
<option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
|
{{ t('DURATION_INPUT.HOURS') }}
|
||||||
|
</option>
|
||||||
|
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const DURATION_UNITS = {
|
||||||
|
MINUTES: 'minutes',
|
||||||
|
HOURS: 'hours',
|
||||||
|
DAYS: 'days',
|
||||||
|
};
|
||||||
@@ -45,9 +45,10 @@ export function useAccount() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateAccount = async data => {
|
const updateAccount = async (data, options) => {
|
||||||
await store.dispatch('accounts/update', {
|
await store.dispatch('accounts/update', {
|
||||||
...data,
|
...data,
|
||||||
|
options,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,31 @@
|
|||||||
},
|
},
|
||||||
"AUTO_RESOLVE": {
|
"AUTO_RESOLVE": {
|
||||||
"TITLE": "Auto-resolve conversations",
|
"TITLE": "Auto-resolve conversations",
|
||||||
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period. Set the duration and customize the message to the user below."
|
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period of inactivity.",
|
||||||
|
"DURATION": {
|
||||||
|
"LABEL": "Inactivity duration",
|
||||||
|
"HELP": "Time period of inactivity after which conversation is auto-resolved",
|
||||||
|
"PLACEHOLDER": "30",
|
||||||
|
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
|
||||||
|
"API": {
|
||||||
|
"SUCCESS": "Auto resolve settings updated successfully",
|
||||||
|
"ERROR": "Failed to update auto resolve settings"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MESSAGE": {
|
||||||
|
"LABEL": "Custom auto-resolution message",
|
||||||
|
"PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
|
||||||
|
"HELP": "Message sent to the customer after conversation is auto-resolved"
|
||||||
|
},
|
||||||
|
"PREFERENCES": "Preferences",
|
||||||
|
"LABEL": {
|
||||||
|
"LABEL": "Add label after auto-resolution",
|
||||||
|
"PLACEHOLDER": "Select a label"
|
||||||
|
},
|
||||||
|
"IGNORE_WAITING": {
|
||||||
|
"LABEL": "Skip conversations waiting for agent’s reply"
|
||||||
|
},
|
||||||
|
"UPDATE_BUTTON": "Save Changes"
|
||||||
},
|
},
|
||||||
"NAME": {
|
"NAME": {
|
||||||
"LABEL": "Account name",
|
"LABEL": "Account name",
|
||||||
@@ -68,24 +92,6 @@
|
|||||||
"PLACEHOLDER": "Your company's support email",
|
"PLACEHOLDER": "Your company's support email",
|
||||||
"ERROR": ""
|
"ERROR": ""
|
||||||
},
|
},
|
||||||
"AUTO_RESOLVE_IGNORE_WAITING": {
|
|
||||||
"LABEL": "Exclude unattended conversations",
|
|
||||||
"HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agent’s reply."
|
|
||||||
},
|
|
||||||
"AUTO_RESOLVE_DURATION": {
|
|
||||||
"LABEL": "Inactivity duration for resolution",
|
|
||||||
"HELP": "Duration after a conversation should auto resolve if there is no activity",
|
|
||||||
"PLACEHOLDER": "30",
|
|
||||||
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
|
|
||||||
"API": {
|
|
||||||
"SUCCESS": "Auto resolve settings updated successfully",
|
|
||||||
"ERROR": "Failed to update auto resolve settings"
|
|
||||||
},
|
|
||||||
"UPDATE_BUTTON": "Update",
|
|
||||||
"MESSAGE_LABEL": "Custom resolution message",
|
|
||||||
"MESSAGE_PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
|
|
||||||
"MESSAGE_HELP": "This message is sent to the customer when a conversation is automatically resolved by the system due to inactivity."
|
|
||||||
},
|
|
||||||
"FEATURES": {
|
"FEATURES": {
|
||||||
"INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",
|
"INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",
|
||||||
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
|
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
|
||||||
|
|||||||
@@ -1,35 +1,78 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { h, ref, watch, computed } from 'vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import SectionLayout from './SectionLayout.vue';
|
import SectionLayout from './SectionLayout.vue';
|
||||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||||
import DurationInput from 'next/input/DurationInput.vue';
|
|
||||||
import TextArea from 'next/textarea/TextArea.vue';
|
import TextArea from 'next/textarea/TextArea.vue';
|
||||||
import Switch from 'next/switch/Switch.vue';
|
import Switch from 'next/switch/Switch.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import DurationInput from 'next/input/DurationInput.vue';
|
||||||
|
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
|
||||||
|
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const duration = ref(0);
|
const duration = ref(0);
|
||||||
|
const unit = ref(DURATION_UNITS.MINUTES);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
|
const labelToApply = ref({});
|
||||||
const ignoreWaiting = ref(false);
|
const ignoreWaiting = ref(false);
|
||||||
const isEnabled = ref(false);
|
const isEnabled = ref(false);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
const { currentAccount, updateAccount } = useAccount();
|
const { currentAccount, updateAccount } = useAccount();
|
||||||
|
|
||||||
|
const labels = useMapGetter('labels/getLabels');
|
||||||
|
|
||||||
|
const labelOptions = computed(() =>
|
||||||
|
labels.value?.length
|
||||||
|
? labels.value.map(label => ({
|
||||||
|
id: label.title,
|
||||||
|
name: label.title,
|
||||||
|
icon: h('span', {
|
||||||
|
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
|
||||||
|
style: { backgroundColor: label.color },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedLabelName = computed(() => {
|
||||||
|
return labelToApply.value?.name ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
currentAccount,
|
[currentAccount, labelOptions],
|
||||||
() => {
|
() => {
|
||||||
const {
|
const {
|
||||||
auto_resolve_after,
|
auto_resolve_after,
|
||||||
auto_resolve_message,
|
auto_resolve_message,
|
||||||
auto_resolve_ignore_waiting,
|
auto_resolve_ignore_waiting,
|
||||||
|
auto_resolve_label,
|
||||||
} = currentAccount.value?.settings || {};
|
} = currentAccount.value?.settings || {};
|
||||||
|
|
||||||
duration.value = auto_resolve_after;
|
duration.value = auto_resolve_after;
|
||||||
message.value = auto_resolve_message;
|
message.value = auto_resolve_message;
|
||||||
ignoreWaiting.value = auto_resolve_ignore_waiting;
|
ignoreWaiting.value = auto_resolve_ignore_waiting;
|
||||||
|
// find the correct label option from the list
|
||||||
|
// the single select component expects the full label object
|
||||||
|
// in our case, the label id and name are both the same
|
||||||
|
labelToApply.value = labelOptions.value.find(
|
||||||
|
option => option.name === auto_resolve_label
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set unit based on duration and its divisibility
|
||||||
|
if (duration.value) {
|
||||||
|
if (duration.value % (24 * 60) === 0) {
|
||||||
|
unit.value = DURATION_UNITS.DAYS;
|
||||||
|
} else if (duration.value % 60 === 0) {
|
||||||
|
unit.value = DURATION_UNITS.HOURS;
|
||||||
|
} else {
|
||||||
|
unit.value = DURATION_UNITS.MINUTES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (duration.value) {
|
if (duration.value) {
|
||||||
isEnabled.value = true;
|
isEnabled.value = true;
|
||||||
@@ -40,16 +83,19 @@ watch(
|
|||||||
|
|
||||||
const updateAccountSettings = async settings => {
|
const updateAccountSettings = async settings => {
|
||||||
try {
|
try {
|
||||||
await updateAccount(settings);
|
isSubmitting.value = true;
|
||||||
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.SUCCESS'));
|
await updateAccount(settings, { silent: true });
|
||||||
|
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.SUCCESS'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.ERROR'));
|
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.ERROR'));
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (duration.value < 10) {
|
if (duration.value < 10) {
|
||||||
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR'));
|
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.ERROR'));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +103,7 @@ const handleSubmit = async () => {
|
|||||||
auto_resolve_after: duration.value,
|
auto_resolve_after: duration.value,
|
||||||
auto_resolve_message: message.value,
|
auto_resolve_message: message.value,
|
||||||
auto_resolve_ignore_waiting: ignoreWaiting.value,
|
auto_resolve_ignore_waiting: ignoreWaiting.value,
|
||||||
|
auto_resolve_label: selectedLabelName.value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,6 +115,7 @@ const handleDisable = async () => {
|
|||||||
auto_resolve_after: null,
|
auto_resolve_after: null,
|
||||||
auto_resolve_message: '',
|
auto_resolve_message: '',
|
||||||
auto_resolve_ignore_waiting: false,
|
auto_resolve_ignore_waiting: false,
|
||||||
|
auto_resolve_label: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,6 +128,7 @@ const toggleAutoResolve = async () => {
|
|||||||
<SectionLayout
|
<SectionLayout
|
||||||
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
|
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
|
||||||
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
|
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
|
||||||
|
:hide-content="!isEnabled"
|
||||||
with-border
|
with-border
|
||||||
>
|
>
|
||||||
<template #headerActions>
|
<template #headerActions>
|
||||||
@@ -90,50 +139,65 @@ const toggleAutoResolve = async () => {
|
|||||||
|
|
||||||
<form class="grid gap-5" @submit.prevent="handleSubmit">
|
<form class="grid gap-5" @submit.prevent="handleSubmit">
|
||||||
<WithLabel
|
<WithLabel
|
||||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL')"
|
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')"
|
||||||
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.HELP')"
|
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')"
|
||||||
>
|
>
|
||||||
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
|
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
|
||||||
<!-- allow 10 mins to 999 days -->
|
<!-- allow 10 mins to 999 days -->
|
||||||
<DurationInput
|
<DurationInput
|
||||||
v-model="duration"
|
v-model="duration"
|
||||||
|
v-model:unit="unit"
|
||||||
min="0"
|
min="0"
|
||||||
max="1439856"
|
max="1438560"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
<WithLabel
|
<WithLabel
|
||||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_LABEL')"
|
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
|
||||||
:help-message="
|
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
|
||||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_HELP')
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<TextArea
|
<TextArea
|
||||||
v-model="message"
|
v-model="message"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_PLACEHOLDER')
|
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
<WithLabel
|
<WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')">
|
||||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.LABEL')"
|
<div
|
||||||
>
|
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
|
||||||
<template #rightOfLabel>
|
>
|
||||||
<Switch v-model="ignoreWaiting" />
|
<div class="p-3 h-12 flex items-center justify-between">
|
||||||
</template>
|
<span>
|
||||||
<p class="text-sm ml-px text-n-slate-10 max-w-lg">
|
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL') }}
|
||||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.HELP') }}
|
</span>
|
||||||
</p>
|
<Switch v-model="ignoreWaiting" />
|
||||||
|
</div>
|
||||||
|
<div class="p-3 h-12 flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
|
||||||
|
</span>
|
||||||
|
<SingleSelect
|
||||||
|
v-model="labelToApply"
|
||||||
|
:options="labelOptions"
|
||||||
|
:placeholder="
|
||||||
|
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
placeholder-icon="i-lucide-chevron-down"
|
||||||
|
placeholder-trailing-icon
|
||||||
|
variant="faded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<NextButton
|
<NextButton
|
||||||
blue
|
blue
|
||||||
type="submit"
|
type="submit"
|
||||||
:label="
|
:is-loading="isSubmitting"
|
||||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.UPDATE_BUTTON')
|
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
defineProps({
|
||||||
title: {
|
title: { type: String, required: true },
|
||||||
type: String,
|
description: { type: String, required: true },
|
||||||
required: true,
|
withBorder: { type: Boolean, default: false },
|
||||||
},
|
hideContent: { type: Boolean, default: false },
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
withBorder: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
class="grid grid-cols-1 py-8 gap-8"
|
class="grid grid-cols-1 pt-8 gap-5 [interpolate-size:allow-keywords]"
|
||||||
:class="{ 'border-t border-n-weak': withBorder }"
|
:class="{
|
||||||
|
'border-t border-n-weak': withBorder,
|
||||||
|
'pb-8': !hideContent,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<header class="grid grid-cols-4">
|
<header class="grid grid-cols-4">
|
||||||
<div class="col-span-3">
|
<div class="col-span-3">
|
||||||
@@ -33,7 +28,10 @@ defineProps({
|
|||||||
<slot name="headerActions" />
|
<slot name="headerActions" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="text-n-slate-12">
|
<div
|
||||||
|
class="transition-[height] duration-300 ease-in-out text-n-slate-12"
|
||||||
|
:class="{ 'overflow-hidden h-0': hideContent, 'h-auto': !hideContent }"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -63,8 +63,11 @@ export const actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update: async ({ commit }, updateObj) => {
|
update: async ({ commit }, { options, ...updateObj }) => {
|
||||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
if (options?.silent !== true) {
|
||||||
|
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await AccountAPI.update('', updateObj);
|
const response = await AccountAPI.update('', updateObj);
|
||||||
commit(types.default.EDIT_ACCOUNT, response.data);
|
commit(types.default.EDIT_ACCOUNT, response.data);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class Conversations::ResolutionJob < ApplicationJob
|
|||||||
# send message from bot that conversation has been resolved
|
# send message from bot that conversation has been resolved
|
||||||
# do this is account.auto_resolve_message is set
|
# do this is account.auto_resolve_message is set
|
||||||
::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present?
|
::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present?
|
||||||
|
conversation.add_labels(account.auto_resolve_label) if account.auto_resolve_label.present?
|
||||||
conversation.toggle_status
|
conversation.toggle_status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ class Account < ApplicationRecord
|
|||||||
{
|
{
|
||||||
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
|
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
|
||||||
'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] },
|
||||||
|
'auto_resolve_label': { 'type': %w[string null] }
|
||||||
},
|
},
|
||||||
'required': [],
|
'required': [],
|
||||||
'additionalProperties': true
|
'additionalProperties': true
|
||||||
@@ -51,7 +52,7 @@ class Account < ApplicationRecord
|
|||||||
schema: SETTINGS_PARAMS_SCHEMA,
|
schema: SETTINGS_PARAMS_SCHEMA,
|
||||||
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, :auto_resolve_label
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ RSpec.describe Conversations::ResolutionJob do
|
|||||||
subject(:job) { described_class.perform_later(account: account) }
|
subject(:job) { described_class.perform_later(account: account) }
|
||||||
|
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
|
let(:label) { create(:label, title: 'auto-resolved', account: account) }
|
||||||
let!(:conversation) { create(:conversation, account: account) }
|
let!(:conversation) { create(:conversation, account: account) }
|
||||||
|
|
||||||
it 'enqueues the job' do
|
it 'enqueues the job' do
|
||||||
@@ -47,6 +48,16 @@ RSpec.describe Conversations::ResolutionJob do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'adds a label after resolution' do
|
||||||
|
account.update(auto_resolve_label: 'auto-resolved', auto_resolve_after: 14_400)
|
||||||
|
conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: 13.days.ago)
|
||||||
|
|
||||||
|
described_class.perform_now(account: account)
|
||||||
|
|
||||||
|
expect(conversation.reload.status).to eq('resolved')
|
||||||
|
expect(conversation.reload.label_list).to include('auto-resolved')
|
||||||
|
end
|
||||||
|
|
||||||
it 'resolves only a limited number of conversations in a single execution' do
|
it 'resolves only a limited number of conversations in a single execution' do
|
||||||
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
|
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
|
||||||
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes
|
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes
|
||||||
|
|||||||
Reference in New Issue
Block a user