fix: V2 Assignment service enhancements (#13036)
## Linear Ticket: https://linear.app/chatwoot/issue/CW-6081/review-feedback ## Description Assignment V2 Service Enhancements - Enable Assignment V2 on plan upgrade - Fix UI issue with fair distribution policy display - Add advanced assignment feature flag and enhance Assignment V2 capabilities ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? This has been tested using the UI. ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes auto-assignment execution paths, rate limiting defaults, and feature-flag gating (including premium plan behavior), which could affect which conversations get assigned and when. UI rewires inbox settings and policy flows, so regressions are possible around navigation/linking and feature visibility. > > **Overview** > **Adds a new premium `advanced_assignment` feature flag** and uses it to gate capacity/balanced assignment features in the UI (sidebar entry, settings routes, assignment-policy landing cards) and backend (Enterprise balanced selector + capacity filtering). `advanced_assignment` is marked premium, included in Business plan entitlements, and auto-synced in Enterprise accounts when `assignment_v2` is toggled. > > **Improves Assignment V2 policy UX** by adding an inbox-level “Conversation Assignment” section (behind `assignment_v2`) that can link/unlink an assignment policy, navigate to create/edit policy flows with `inboxId` query context, and show an inbox-link prompt after creating a policy. The policy form now defaults to enabled, disables the `balanced` option with a premium badge/message when unavailable, and inbox lists support click-to-navigate. > > **Tightens/adjusts auto-assignment behavior**: bulk assignment now requires `inbox.enable_auto_assignment?`, conversation ordering uses the attached `assignment_policy` priority, and rate limiting uses `assignment_policy` config with an infinite default limit while still tracking assignments. Tests and i18n strings are updated accordingly. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 23bc03bf75ee4376071e4d7fc7cd564c601d33d7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Pranav <pranav@chatwoot.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
committed by
GitHub
parent
8f95fafff4
commit
7b512bd00e
@@ -39,7 +39,6 @@ const policyA = withCount({
|
||||
description: 'Distributes conversations evenly among available agents',
|
||||
assignmentOrder: 'round_robin',
|
||||
conversationPriority: 'high',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[0], mockInboxes[1]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
@@ -50,7 +49,6 @@ const policyB = withCount({
|
||||
description: 'Assigns based on capacity and workload',
|
||||
assignmentOrder: 'capacity_based',
|
||||
conversationPriority: 'medium',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[2], mockInboxes[3]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
@@ -61,7 +59,6 @@ const emptyPolicy = withCount({
|
||||
description: 'Policy with no assigned inboxes',
|
||||
assignmentOrder: 'manual',
|
||||
conversationPriority: 'low',
|
||||
enabled: false,
|
||||
inboxes: [],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ const props = defineProps({
|
||||
assignmentOrder: { type: String, default: '' },
|
||||
conversationPriority: { type: String, default: '' },
|
||||
assignedInboxCount: { type: Number, default: 0 },
|
||||
enabled: { type: Boolean, default: false },
|
||||
inboxes: { type: Array, default: () => [] },
|
||||
isFetchingInboxes: { type: Boolean, default: false },
|
||||
});
|
||||
@@ -65,22 +64,6 @@ const handleFetchInboxes = () => {
|
||||
{{ name }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
|
||||
>
|
||||
{{
|
||||
enabled
|
||||
? t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
|
||||
)
|
||||
: t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<CardPopover
|
||||
:title="
|
||||
t(
|
||||
|
||||
@@ -19,11 +19,15 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
const emit = defineEmits(['delete', 'navigate']);
|
||||
|
||||
const handleDelete = itemId => {
|
||||
emit('delete', itemId);
|
||||
};
|
||||
|
||||
const handleNavigate = item => {
|
||||
emit('navigate', item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,7 +51,11 @@ const handleDelete = itemId => {
|
||||
:key="item.id"
|
||||
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 col-span-2 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 rounded-lg py-1 px-1.5 -ml-1.5 transition-colors cursor-pointer group"
|
||||
@click="handleNavigate(item)"
|
||||
>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
@@ -61,10 +69,16 @@ const handleDelete = itemId => {
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 truncate min-w-0">
|
||||
<span
|
||||
class="text-sm text-n-slate-12 truncate min-w-0 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
<Icon
|
||||
icon="i-lucide-external-link"
|
||||
class="size-3.5 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-2 col-span-1">
|
||||
<span
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
@@ -15,6 +15,9 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', {
|
||||
},
|
||||
});
|
||||
|
||||
// The model value is in seconds (for the backend/DB)
|
||||
// DurationInput works in minutes internally
|
||||
// We need to convert between seconds and minutes
|
||||
const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||
type: Number,
|
||||
default: 3600,
|
||||
@@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||
|
||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
||||
|
||||
// Convert seconds to minutes for DurationInput
|
||||
const windowInMinutes = computed({
|
||||
get() {
|
||||
return Math.floor((fairDistributionWindow.value || 0) / 60);
|
||||
},
|
||||
set(minutes) {
|
||||
fairDistributionWindow.value = minutes * 60;
|
||||
},
|
||||
});
|
||||
|
||||
// Detect unit based on minutes (converted from seconds)
|
||||
const detectUnit = minutes => {
|
||||
const m = Number(minutes) || 0;
|
||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||
@@ -34,7 +48,7 @@ const detectUnit = minutes => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
windowUnit.value = detectUnit(fairDistributionWindow.value);
|
||||
windowUnit.value = detectUnit(windowInMinutes.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -73,9 +87,9 @@ onMounted(() => {
|
||||
<div
|
||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||
>
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<!-- allow 10 mins to 999 days (in minutes) -->
|
||||
<DurationInput
|
||||
v-model:model-value="fairDistributionWindow"
|
||||
v-model:model-value="windowInMinutes"
|
||||
v-model:unit="windowUnit"
|
||||
:min="10"
|
||||
:max="1438560"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
@@ -16,12 +18,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabledMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleChange = () => {
|
||||
if (!props.isActive) {
|
||||
if (!props.isActive && !props.disabled) {
|
||||
emit('select', props.id);
|
||||
}
|
||||
};
|
||||
@@ -29,9 +41,11 @@ const handleChange = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
||||
class="relative rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
||||
:class="[
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
|
||||
!disabled && !isActive ? 'hover:outline-n-strong' : '',
|
||||
]"
|
||||
@click="handleChange"
|
||||
>
|
||||
@@ -41,6 +55,7 @@ const handleChange = () => {
|
||||
:checked="isActive"
|
||||
:value="id"
|
||||
:name="id"
|
||||
:disabled="disabled"
|
||||
type="radio"
|
||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
||||
@change="handleChange"
|
||||
@@ -49,11 +64,23 @@ const handleChange = () => {
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col gap-3 items-start">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="disabled"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ description }}
|
||||
{{ disabled && disabledMessage ? disabledMessage : description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ const policyName = ref('Round Robin Policy');
|
||||
const description = ref(
|
||||
'Distributes conversations evenly among available agents'
|
||||
);
|
||||
const enabled = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,13 +18,10 @@ const enabled = ref(true);
|
||||
<BaseInfo
|
||||
v-model:policy-name="policyName"
|
||||
v-model:description="description"
|
||||
v-model:enabled="enabled"
|
||||
name-label="Policy Name"
|
||||
name-placeholder="Enter policy name"
|
||||
description-label="Description"
|
||||
description-placeholder="Enter policy description"
|
||||
status-label="Status"
|
||||
status-placeholder="Active"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
@@ -50,6 +51,18 @@ const isRTL = useMapGetter('accounts/isRTL');
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const hasAdvancedAssignment = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
|
||||
);
|
||||
});
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -584,12 +597,16 @@ const menuItems = computed(() => {
|
||||
icon: 'i-lucide-users',
|
||||
to: accountScopedRoute('settings_teams_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Agent Assignment',
|
||||
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
||||
icon: 'i-lucide-user-cog',
|
||||
to: accountScopedRoute('assignment_policy_index'),
|
||||
},
|
||||
...(hasAdvancedAssignment.value
|
||||
? [
|
||||
{
|
||||
name: 'Settings Agent Assignment',
|
||||
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
||||
icon: 'i-lucide-user-cog',
|
||||
to: accountScopedRoute('assignment_policy_index'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Settings Inboxes',
|
||||
label: t('SIDEBAR.INBOXES'),
|
||||
|
||||
@@ -2,6 +2,7 @@ export const FEATURE_FLAGS = {
|
||||
AGENT_BOTS: 'agent_bots',
|
||||
AGENT_MANAGEMENT: 'agent_management',
|
||||
ASSIGNMENT_V2: 'assignment_v2',
|
||||
ADVANCED_ASSIGNMENT: 'advanced_assignment',
|
||||
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
|
||||
AUTOMATIONS: 'automations',
|
||||
CAMPAIGNS: 'campaigns',
|
||||
@@ -56,4 +57,5 @@ export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
FEATURE_FLAGS.SAML,
|
||||
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
|
||||
FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||
];
|
||||
|
||||
@@ -766,6 +766,53 @@
|
||||
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0",
|
||||
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
|
||||
},
|
||||
"ASSIGNMENT": {
|
||||
"TITLE": "Conversation Assignment",
|
||||
"DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies",
|
||||
"ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment",
|
||||
"DEFAULT_RULES_TITLE": "Default assignment rules",
|
||||
"DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations",
|
||||
"DEFAULT_RULE_1": "Earliest created conversations first",
|
||||
"DEFAULT_RULE_2": "Round robin distribution",
|
||||
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
|
||||
"USING_POLICY": "Using custom assignment policy for this inbox",
|
||||
"CUSTOMIZE_POLICY": "Customize with assignment policy",
|
||||
"DELETE_POLICY": "Delete policy",
|
||||
"POLICY_LABEL": "Assignment policy",
|
||||
"ASSIGNMENT_ORDER_LABEL": "Assignment Order",
|
||||
"ASSIGNMENT_METHOD_LABEL": "Assignment Method",
|
||||
"POLICY_STATUS": {
|
||||
"ACTIVE": "Active",
|
||||
"INACTIVE": "Inactive"
|
||||
},
|
||||
"PRIORITY": {
|
||||
"EARLIEST_CREATED": "Earliest created",
|
||||
"LONGEST_WAITING": "Longest waiting"
|
||||
},
|
||||
"METHOD": {
|
||||
"ROUND_ROBIN": "Round robin",
|
||||
"BALANCED": "Balanced assignment"
|
||||
},
|
||||
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
|
||||
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
|
||||
"DEFAULT_POLICY_LINKED": "Default policy linked",
|
||||
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
|
||||
"LINK_EXISTING_POLICY": "Link existing policy",
|
||||
"CREATE_NEW_POLICY": "Create new policy",
|
||||
"NO_POLICIES": "No assignment policies found",
|
||||
"VIEW_ALL_POLICIES": "View all policies",
|
||||
"CURRENT_BEHAVIOR": "Currently using default assignment behavior:",
|
||||
"LINK_SUCCESS": "Assignment policy linked successfully",
|
||||
"LINK_ERROR": "Failed to link assignment policy"
|
||||
},
|
||||
"ASSIGNMENT_POLICY": {
|
||||
"DELETE_CONFIRM_TITLE": "Delete assignment policy?",
|
||||
"DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.",
|
||||
"CANCEL": "Cancel",
|
||||
"CONFIRM_DELETE": "Delete",
|
||||
"DELETE_SUCCESS": "Assignment policy removed successfully",
|
||||
"DELETE_ERROR": "Failed to remove assignment policy"
|
||||
},
|
||||
"FACEBOOK_REAUTHORIZE": {
|
||||
"TITLE": "Reauthorize",
|
||||
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
|
||||
|
||||
@@ -694,7 +694,8 @@
|
||||
"CREATE_BUTTON": "Create policy",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Assignment policy created successfully",
|
||||
"ERROR_MESSAGE": "Failed to create assignment policy"
|
||||
"ERROR_MESSAGE": "Failed to create assignment policy",
|
||||
"INBOX_LINKED": "Inbox has been linked to the policy"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
@@ -708,6 +709,12 @@
|
||||
"CONFIRM_BUTTON_LABEL": "Continue",
|
||||
"CANCEL_BUTTON_LABEL": "Cancel"
|
||||
},
|
||||
"INBOX_LINK_PROMPT": {
|
||||
"TITLE": "Link inbox to policy",
|
||||
"DESCRIPTION": "Would you like to link this inbox to the assignment policy?",
|
||||
"LINK_BUTTON": "Link inbox",
|
||||
"CANCEL_BUTTON": "Skip"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Assignment policy updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update assignment policy"
|
||||
@@ -746,7 +753,9 @@
|
||||
},
|
||||
"BALANCED": {
|
||||
"LABEL": "Balanced",
|
||||
"DESCRIPTION": "Assign conversations based on available capacity."
|
||||
"DESCRIPTION": "Assign conversations based on available capacity.",
|
||||
"PREMIUM_MESSAGE": "Upgrade to access balanced assignment and agent capacity management.",
|
||||
"PREMIUM_BADGE": "Premium"
|
||||
}
|
||||
},
|
||||
"ASSIGNMENT_PRIORITY": {
|
||||
@@ -832,6 +841,20 @@
|
||||
"SUCCESS_MESSAGE": "Agent removed from policy successfully",
|
||||
"ERROR_MESSAGE": "Failed to remove agent from policy"
|
||||
}
|
||||
},
|
||||
"INBOX_LIMIT_API": {
|
||||
"ADD": {
|
||||
"SUCCESS_MESSAGE": "Inbox limit added successfully",
|
||||
"ERROR_MESSAGE": "Failed to add inbox limit"
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS_MESSAGE": "Inbox limit updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update inbox limit"
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS_MESSAGE": "Inbox limit deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete inbox limit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FORM": {
|
||||
|
||||
@@ -1,54 +1,81 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
const agentAssignments = computed(() => [
|
||||
{
|
||||
key: 'agent_assignment_policy_index',
|
||||
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
|
||||
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-circle-fading-arrow-up',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-inbox',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'agent_capacity_policy_index',
|
||||
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
|
||||
description: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'),
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-glass-water',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-circle-minus',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-users-round',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const accountId = computed(() => Number(route.params.accountId));
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const agentAssignments = computed(() => {
|
||||
const assignments = [
|
||||
{
|
||||
key: 'agent_assignment_policy_index',
|
||||
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
|
||||
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-circle-fading-arrow-up',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-inbox',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Only show Agent Capacity if BOTH assignment_v2 AND advanced_assignment are enabled
|
||||
// advanced_assignment identifies premium users
|
||||
const hasAssignmentV2 = isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
'assignment_v2'
|
||||
);
|
||||
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
'advanced_assignment'
|
||||
);
|
||||
|
||||
if (hasAssignmentV2 && hasAdvancedAssignment) {
|
||||
assignments.push({
|
||||
key: 'agent_capacity_policy_index',
|
||||
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
|
||||
description: t(
|
||||
'ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'
|
||||
),
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-glass-water',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-circle-minus',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-users-round',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return assignments;
|
||||
});
|
||||
|
||||
const handleClick = key => {
|
||||
router.push({ name: key });
|
||||
|
||||
@@ -62,7 +62,7 @@ export default {
|
||||
name: 'agent_capacity_policy_index',
|
||||
component: AgentCapacityIndex,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
||||
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
name: 'agent_capacity_policy_create',
|
||||
component: AgentCapacityCreate,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
||||
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
@@ -80,7 +80,7 @@ export default {
|
||||
name: 'agent_capacity_policy_edit',
|
||||
component: AgentCapacityEdit,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
||||
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
|
||||
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
@@ -16,20 +17,50 @@ const { t } = useI18n();
|
||||
const formRef = ref(null);
|
||||
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
|
||||
routeName: 'agent_assignment_policy_index',
|
||||
},
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
|
||||
},
|
||||
]);
|
||||
const inboxIdFromQuery = computed(() => {
|
||||
const id = route.query.inboxId;
|
||||
return id ? Number(id) : null;
|
||||
});
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (inboxIdFromQuery.value) {
|
||||
return [
|
||||
{
|
||||
label: t('INBOX_MGMT.SETTINGS'),
|
||||
routeName: 'settings_inbox_show',
|
||||
params: { inboxId: inboxIdFromQuery.value },
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
|
||||
routeName: 'agent_assignment_policy_index',
|
||||
},
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const handleBreadcrumbClick = item => {
|
||||
router.push({
|
||||
name: item.routeName,
|
||||
});
|
||||
if (item.params) {
|
||||
const accountId = route.params.accountId;
|
||||
const inboxId = item.params.inboxId;
|
||||
// Navigate using explicit path to ensure tab parameter is included
|
||||
router.push(
|
||||
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
|
||||
);
|
||||
} else {
|
||||
router.push({
|
||||
name: item.routeName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async formState => {
|
||||
@@ -45,6 +76,8 @@ const handleSubmit = async formState => {
|
||||
params: {
|
||||
id: policy.id,
|
||||
},
|
||||
// Pass inboxId to edit page to show link prompt
|
||||
query: inboxIdFromQuery.value ? { inboxId: inboxIdFromQuery.value } : {},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
|
||||
@@ -14,6 +14,7 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
|
||||
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
|
||||
import ConfirmInboxDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue';
|
||||
import InboxLinkDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/InboxLinkDialog.vue';
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
|
||||
|
||||
@@ -36,13 +37,46 @@ const confirmInboxDialogRef = ref(null);
|
||||
// Store the policy linked to the inbox when adding a new inbox
|
||||
const inboxLinkedPolicy = ref(null);
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{
|
||||
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
|
||||
routeName: 'agent_assignment_policy_index',
|
||||
},
|
||||
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
||||
]);
|
||||
// Inbox linking prompt from create flow
|
||||
const inboxIdFromQuery = computed(() => {
|
||||
const id = route.query.inboxId;
|
||||
return id ? Number(id) : null;
|
||||
});
|
||||
|
||||
const suggestedInbox = computed(() => {
|
||||
if (!inboxIdFromQuery.value || !inboxes.value) return null;
|
||||
return inboxes.value.find(inbox => inbox.id === inboxIdFromQuery.value);
|
||||
});
|
||||
|
||||
const isLinkingInbox = ref(false);
|
||||
|
||||
const dismissInboxLinkPrompt = () => {
|
||||
router.replace({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: {},
|
||||
});
|
||||
};
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (inboxIdFromQuery.value) {
|
||||
return [
|
||||
{
|
||||
label: t('INBOX_MGMT.SETTINGS'),
|
||||
routeName: 'settings_inbox_show',
|
||||
params: { inboxId: inboxIdFromQuery.value },
|
||||
},
|
||||
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
|
||||
routeName: 'agent_assignment_policy_index',
|
||||
},
|
||||
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
||||
];
|
||||
});
|
||||
|
||||
const buildInboxList = allInboxes =>
|
||||
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
||||
@@ -66,22 +100,48 @@ const inboxList = computed(() =>
|
||||
const formData = computed(() => ({
|
||||
name: selectedPolicy.value?.name || '',
|
||||
description: selectedPolicy.value?.description || '',
|
||||
enabled: selectedPolicy.value?.enabled || false,
|
||||
enabled: true,
|
||||
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
|
||||
conversationPriority:
|
||||
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
|
||||
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 10,
|
||||
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 60,
|
||||
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
|
||||
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
|
||||
}));
|
||||
|
||||
const handleDeleteInbox = inboxId =>
|
||||
store.dispatch('assignmentPolicies/removeInboxPolicy', {
|
||||
policyId: selectedPolicy.value?.id,
|
||||
inboxId,
|
||||
});
|
||||
const handleDeleteInbox = async inboxId => {
|
||||
try {
|
||||
await store.dispatch('assignmentPolicies/removeInboxPolicy', {
|
||||
policyId: selectedPolicy.value?.id,
|
||||
inboxId,
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBreadcrumbClick = ({ routeName }) =>
|
||||
router.push({ name: routeName });
|
||||
const handleBreadcrumbClick = ({ routeName, params }) => {
|
||||
if (params) {
|
||||
const accountId = route.params.accountId;
|
||||
const inboxId = params.inboxId;
|
||||
// Navigate using explicit path to ensure tab parameter is included
|
||||
router.push(
|
||||
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
|
||||
);
|
||||
} else {
|
||||
router.push({ name: routeName });
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigateToInbox = inbox => {
|
||||
router.push({
|
||||
name: 'settings_inbox_show',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
inboxId: inbox.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setInboxPolicy = async (inboxId, policyId) => {
|
||||
try {
|
||||
@@ -122,6 +182,26 @@ const handleAddInbox = async inbox => {
|
||||
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id);
|
||||
};
|
||||
|
||||
const handleLinkSuggestedInbox = async () => {
|
||||
if (!suggestedInbox.value) return;
|
||||
|
||||
isLinkingInbox.value = true;
|
||||
const inbox = {
|
||||
id: suggestedInbox.value.id,
|
||||
name: suggestedInbox.value.name,
|
||||
};
|
||||
|
||||
await handleAddInbox(inbox);
|
||||
|
||||
// Clear the query param after linking
|
||||
router.replace({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: {},
|
||||
});
|
||||
isLinkingInbox.value = false;
|
||||
};
|
||||
|
||||
const handleConfirmAddInbox = async inboxId => {
|
||||
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
|
||||
|
||||
@@ -155,6 +235,11 @@ const handleSubmit = async formState => {
|
||||
const fetchPolicyData = async () => {
|
||||
if (!routeId.value) return;
|
||||
|
||||
// Fetch inboxes if not already loaded (needed for inbox link prompt)
|
||||
if (!inboxes.value?.length) {
|
||||
store.dispatch('inboxes/get');
|
||||
}
|
||||
|
||||
// Fetch policy if not available
|
||||
if (!selectedPolicy.value?.id)
|
||||
await store.dispatch('assignmentPolicies/show', routeId.value);
|
||||
@@ -186,6 +271,7 @@ watch(routeId, fetchPolicyData, { immediate: true });
|
||||
@submit="handleSubmit"
|
||||
@add-inbox="handleAddInbox"
|
||||
@delete-inbox="handleDeleteInbox"
|
||||
@navigate-to-inbox="handleNavigateToInbox"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -193,5 +279,12 @@ watch(routeId, fetchPolicyData, { immediate: true });
|
||||
ref="confirmInboxDialogRef"
|
||||
@add="handleConfirmAddInbox"
|
||||
/>
|
||||
|
||||
<InboxLinkDialog
|
||||
:inbox="suggestedInbox"
|
||||
:is-linking="isLinkingInbox"
|
||||
@link="handleLinkSuggestedInbox"
|
||||
@dismiss="dismissInboxLinkPrompt"
|
||||
/>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
@@ -92,43 +92,68 @@ const formData = computed(() => ({
|
||||
const handleBreadcrumbClick = ({ routeName }) =>
|
||||
router.push({ name: routeName });
|
||||
|
||||
const handleDeleteUser = agentId => {
|
||||
store.dispatch('agentCapacityPolicies/removeUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userId: agentId,
|
||||
});
|
||||
const handleDeleteUser = async agentId => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/removeUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userId: agentId,
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUser = agent => {
|
||||
store.dispatch('agentCapacityPolicies/addUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userData: { id: agent.id, capacity: 20 },
|
||||
});
|
||||
const handleAddUser = async agent => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/addUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userData: { id: agent.id, capacity: 20 },
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInboxLimit = limitId => {
|
||||
store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId,
|
||||
});
|
||||
const handleDeleteInboxLimit = async limitId => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId,
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddInboxLimit = limit => {
|
||||
store.dispatch('agentCapacityPolicies/createInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitData: {
|
||||
inboxId: limit.inboxId,
|
||||
conversationLimit: limit.conversationLimit,
|
||||
},
|
||||
});
|
||||
const handleAddInboxLimit = async limit => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/createInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitData: {
|
||||
inboxId: limit.inboxId,
|
||||
conversationLimit: limit.conversationLimit,
|
||||
},
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLimitChange = limit => {
|
||||
store.dispatch('agentCapacityPolicies/updateInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId: limit.id,
|
||||
limitData: { conversationLimit: limit.conversationLimit },
|
||||
});
|
||||
const handleLimitChange = async limit => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/updateInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId: limit.id,
|
||||
limitData: { conversationLimit: limit.conversationLimit },
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async formState => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
|
||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
||||
@@ -23,7 +24,6 @@ const props = defineProps({
|
||||
default: () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
assignmentOrder: ROUND_ROBIN,
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
@@ -61,18 +61,24 @@ const emit = defineEmits([
|
||||
'submit',
|
||||
'addInbox',
|
||||
'deleteInbox',
|
||||
'navigateToInbox',
|
||||
'validationChange',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isEnterprise } = useConfig();
|
||||
const route = useRoute();
|
||||
|
||||
const accountId = computed(() => Number(route.params.accountId));
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
|
||||
|
||||
const state = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
assignmentOrder: ROUND_ROBIN,
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
@@ -83,20 +89,42 @@ const validationState = ref({
|
||||
isValid: false,
|
||||
});
|
||||
|
||||
const createOption = (type, key, stateKey) => ({
|
||||
const createOption = (
|
||||
type,
|
||||
key,
|
||||
stateKey,
|
||||
disabled = false,
|
||||
disabledMessage = ''
|
||||
) => ({
|
||||
key,
|
||||
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
|
||||
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
|
||||
isActive: state[stateKey] === key,
|
||||
disabled,
|
||||
disabledMessage,
|
||||
});
|
||||
|
||||
const assignmentOrderOptions = computed(() => {
|
||||
const options = OPTIONS.ORDER.filter(
|
||||
key => isEnterprise || key !== 'balanced'
|
||||
);
|
||||
return options.map(key =>
|
||||
createOption('ASSIGNMENT_ORDER', key, 'assignmentOrder')
|
||||
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
'advanced_assignment'
|
||||
);
|
||||
|
||||
return OPTIONS.ORDER.map(key => {
|
||||
const isBalanced = key === 'balanced';
|
||||
const disabled = isBalanced && !hasAdvancedAssignment;
|
||||
const disabledMessage = disabled
|
||||
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_MESSAGE`)
|
||||
: '';
|
||||
|
||||
return createOption(
|
||||
'ASSIGNMENT_ORDER',
|
||||
key,
|
||||
'assignmentOrder',
|
||||
disabled,
|
||||
disabledMessage
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const assignmentPriorityOptions = computed(() =>
|
||||
@@ -131,7 +159,7 @@ const resetForm = () => {
|
||||
Object.assign(state, {
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
assignmentOrder: ROUND_ROBIN,
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
@@ -162,15 +190,10 @@ defineExpose({
|
||||
<BaseInfo
|
||||
v-model:policy-name="state.name"
|
||||
v-model:description="state.description"
|
||||
v-model:enabled="state.enabled"
|
||||
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
|
||||
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
|
||||
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
|
||||
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
|
||||
:status-label="t(`${BASE_KEY}.FORM.STATUS.LABEL`)"
|
||||
:status-placeholder="
|
||||
t(`${BASE_KEY}.FORM.STATUS.${state.enabled ? 'ACTIVE' : 'INACTIVE'}`)
|
||||
"
|
||||
@validation-change="handleValidationChange"
|
||||
/>
|
||||
|
||||
@@ -193,6 +216,8 @@ defineExpose({
|
||||
:label="option.label"
|
||||
:description="option.description"
|
||||
:is-active="option.isActive"
|
||||
:disabled="option.disabled"
|
||||
:disabled-message="option.disabledMessage"
|
||||
@select="state[section.key] = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -251,6 +276,7 @@ defineExpose({
|
||||
:is-fetching="isInboxLoading"
|
||||
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
|
||||
@delete="$emit('deleteInbox', $event)"
|
||||
@navigate="$emit('navigateToInbox', $event)"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isLinking: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['link', 'dismiss']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
if (!props.inbox) return 'i-lucide-inbox';
|
||||
return getInboxIconByType(
|
||||
props.inbox.channelType,
|
||||
props.inbox.medium,
|
||||
'line'
|
||||
);
|
||||
});
|
||||
|
||||
const openDialog = () => {
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('link');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('dismiss');
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.inbox,
|
||||
async newInbox => {
|
||||
if (newInbox) {
|
||||
await nextTick();
|
||||
openDialog();
|
||||
} else {
|
||||
closeDialog();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
defineExpose({ openDialog, closeDialog });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.TITLE'
|
||||
)
|
||||
"
|
||||
:confirm-button-label="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.LINK_BUTTON'
|
||||
)
|
||||
"
|
||||
:cancel-button-label="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.CANCEL_BUTTON'
|
||||
)
|
||||
"
|
||||
:is-loading="isLinking"
|
||||
@confirm="handleConfirm"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #description>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 rounded-xl border border-n-weak bg-n-alpha-1"
|
||||
>
|
||||
<div
|
||||
class="flex-shrink-0 size-10 rounded-lg bg-n-alpha-2 flex items-center justify-center"
|
||||
>
|
||||
<i :class="inboxIcon" class="text-lg text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-sm font-medium text-n-slate-12 truncate">
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,122 +1,321 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { minValue } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import SettingsSection from '../../../../../components/SettingsSection.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import assignmentPoliciesAPI from 'dashboard/api/assignmentPolicies';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingsSection,
|
||||
NextButton,
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { isEnterprise } = useConfig();
|
||||
});
|
||||
|
||||
return { v$: useVuelidate(), isEnterprise };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedAgents: [],
|
||||
isAgentListUpdating: false,
|
||||
enableAutoAssignment: false,
|
||||
maxAssignmentLimit: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
agentList: 'agents/getAgents',
|
||||
}),
|
||||
maxAssignmentLimitErrors() {
|
||||
if (this.v$.maxAssignmentLimit.$error) {
|
||||
return this.$t(
|
||||
'INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR'
|
||||
);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
inbox() {
|
||||
this.setDefaults();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setDefaults();
|
||||
},
|
||||
methods: {
|
||||
setDefaults() {
|
||||
this.enableAutoAssignment = this.inbox.enable_auto_assignment;
|
||||
this.maxAssignmentLimit =
|
||||
this.inbox?.auto_assignment_config?.max_assignment_limit || null;
|
||||
this.fetchAttachedAgents();
|
||||
},
|
||||
async fetchAttachedAgents() {
|
||||
try {
|
||||
const response = await this.$store.dispatch('inboxMembers/get', {
|
||||
inboxId: this.inbox.id,
|
||||
});
|
||||
const {
|
||||
data: { payload: inboxMembers },
|
||||
} = response;
|
||||
this.selectedAgents = inboxMembers;
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
},
|
||||
handleEnableAutoAssignment() {
|
||||
this.updateInbox();
|
||||
},
|
||||
async updateAgents() {
|
||||
const agentList = this.selectedAgents.map(el => el.id);
|
||||
this.isAgentListUpdating = true;
|
||||
try {
|
||||
await this.$store.dispatch('inboxMembers/create', {
|
||||
inboxId: this.inbox.id,
|
||||
agentList,
|
||||
});
|
||||
useAlert(this.$t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
this.isAgentListUpdating = false;
|
||||
},
|
||||
async updateInbox() {
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
enable_auto_assignment: this.enableAutoAssignment,
|
||||
auto_assignment_config: {
|
||||
max_assignment_limit: this.maxAssignmentLimit,
|
||||
},
|
||||
};
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
}
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
selectedAgents: {
|
||||
isEmpty() {
|
||||
return !!this.selectedAgents.length;
|
||||
},
|
||||
},
|
||||
maxAssignmentLimit: {
|
||||
minValue: minValue(1),
|
||||
},
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const selectedAgents = ref([]);
|
||||
const isAgentListUpdating = ref(false);
|
||||
const enableAutoAssignment = ref(false);
|
||||
const maxAssignmentLimit = ref(null);
|
||||
const assignmentPolicy = ref(null);
|
||||
const isLoadingPolicy = ref(false);
|
||||
const isDeletingPolicy = ref(false);
|
||||
const showDeleteConfirmModal = ref(false);
|
||||
const availablePolicies = ref([]);
|
||||
const isLoadingPolicies = ref(false);
|
||||
const showPolicyDropdown = ref(false);
|
||||
const isLinkingPolicy = ref(false);
|
||||
|
||||
const agentList = computed(() => store.getters['agents/getAgents']);
|
||||
|
||||
const isFeatureEnabled = feature => {
|
||||
const accountId = Number(route.params.accountId);
|
||||
return store.getters['accounts/isFeatureEnabledonAccount'](
|
||||
accountId,
|
||||
feature
|
||||
);
|
||||
};
|
||||
|
||||
const hasAdvancedAssignment = computed(() => {
|
||||
return isFeatureEnabled('advanced_assignment');
|
||||
});
|
||||
|
||||
const hasAssignmentV2 = computed(() => {
|
||||
return isFeatureEnabled('assignment_v2');
|
||||
});
|
||||
|
||||
const showAdvancedAssignmentUI = computed(() => {
|
||||
return hasAdvancedAssignment.value && hasAssignmentV2.value;
|
||||
});
|
||||
|
||||
const assignmentOrderLabel = computed(() => {
|
||||
if (!assignmentPolicy.value) return '';
|
||||
const priority = assignmentPolicy.value.conversation_priority;
|
||||
if (priority === 'earliest_created') {
|
||||
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.EARLIEST_CREATED');
|
||||
}
|
||||
if (priority === 'longest_waiting') {
|
||||
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.LONGEST_WAITING');
|
||||
}
|
||||
return priority;
|
||||
});
|
||||
|
||||
const assignmentMethodLabel = computed(() => {
|
||||
if (!assignmentPolicy.value) return '';
|
||||
const order = assignmentPolicy.value.assignment_order;
|
||||
if (order === 'round_robin') {
|
||||
return t('INBOX_MGMT.ASSIGNMENT.METHOD.ROUND_ROBIN');
|
||||
}
|
||||
if (order === 'balanced') {
|
||||
return t('INBOX_MGMT.ASSIGNMENT.METHOD.BALANCED');
|
||||
}
|
||||
return order;
|
||||
});
|
||||
|
||||
// Vuelidate validation rules
|
||||
const rules = {
|
||||
maxAssignmentLimit: {
|
||||
minValue: minValue(1),
|
||||
},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, { maxAssignmentLimit });
|
||||
|
||||
const maxAssignmentLimitErrors = computed(() => {
|
||||
if (v$.value.maxAssignmentLimit.$error) {
|
||||
return t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const fetchAttachedAgents = async () => {
|
||||
try {
|
||||
const response = await store.dispatch('inboxMembers/get', {
|
||||
inboxId: props.inbox.id,
|
||||
});
|
||||
const {
|
||||
data: { payload: inboxMembers },
|
||||
} = response;
|
||||
selectedAgents.value = inboxMembers;
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAssignmentPolicy = async () => {
|
||||
if (!props.inbox.id) return;
|
||||
|
||||
isLoadingPolicy.value = true;
|
||||
try {
|
||||
const response = await assignmentPoliciesAPI.getInboxPolicy(props.inbox.id);
|
||||
assignmentPolicy.value = response.data;
|
||||
} catch (error) {
|
||||
// No policy attached, which is fine
|
||||
assignmentPolicy.value = null;
|
||||
} finally {
|
||||
isLoadingPolicy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAvailablePolicies = async () => {
|
||||
isLoadingPolicies.value = true;
|
||||
try {
|
||||
const response = await assignmentPoliciesAPI.get();
|
||||
availablePolicies.value = response.data;
|
||||
} catch (error) {
|
||||
availablePolicies.value = [];
|
||||
} finally {
|
||||
isLoadingPolicies.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const linkPolicyToInbox = async policy => {
|
||||
isLinkingPolicy.value = true;
|
||||
try {
|
||||
await assignmentPoliciesAPI.setInboxPolicy(props.inbox.id, policy.id);
|
||||
assignmentPolicy.value = policy;
|
||||
showPolicyDropdown.value = false;
|
||||
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_ERROR'));
|
||||
} finally {
|
||||
isLinkingPolicy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToAssignmentPolicies = () => {
|
||||
const accountId = route.params.accountId;
|
||||
router.push({
|
||||
name: 'agent_assignment_policy_index',
|
||||
params: { accountId },
|
||||
});
|
||||
};
|
||||
|
||||
const policyMenuItems = computed(() => {
|
||||
const items = availablePolicies.value.map(policy => ({
|
||||
action: 'select_policy',
|
||||
value: policy.id,
|
||||
label: policy.name,
|
||||
icon: 'i-lucide-zap',
|
||||
policy,
|
||||
}));
|
||||
|
||||
items.push({
|
||||
action: 'view_all',
|
||||
value: 'view_all',
|
||||
label: t('INBOX_MGMT.ASSIGNMENT.VIEW_ALL_POLICIES'),
|
||||
icon: 'i-lucide-arrow-right',
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handlePolicyMenuAction = ({ action, policy }) => {
|
||||
if (action === 'select_policy' && policy) {
|
||||
linkPolicyToInbox(policy);
|
||||
} else if (action === 'view_all') {
|
||||
navigateToAssignmentPolicies();
|
||||
}
|
||||
showPolicyDropdown.value = false;
|
||||
};
|
||||
|
||||
const togglePolicyDropdown = () => {
|
||||
if (!showPolicyDropdown.value && availablePolicies.value.length === 0) {
|
||||
fetchAvailablePolicies();
|
||||
}
|
||||
showPolicyDropdown.value = !showPolicyDropdown.value;
|
||||
};
|
||||
|
||||
const closePolicyDropdown = () => {
|
||||
showPolicyDropdown.value = false;
|
||||
};
|
||||
|
||||
const handleToggleAutoAssignment = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
id: props.inbox.id,
|
||||
formData: false,
|
||||
enable_auto_assignment: enableAutoAssignment.value,
|
||||
};
|
||||
await store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const updateAgents = async () => {
|
||||
const agentListIds = selectedAgents.value.map(el => el.id);
|
||||
isAgentListUpdating.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxMembers/create', {
|
||||
inboxId: props.inbox.id,
|
||||
agentList: agentListIds,
|
||||
});
|
||||
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
isAgentListUpdating.value = false;
|
||||
};
|
||||
|
||||
const updateInbox = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
id: props.inbox.id,
|
||||
formData: false,
|
||||
enable_auto_assignment: enableAutoAssignment.value,
|
||||
auto_assignment_config: {
|
||||
max_assignment_limit: maxAssignmentLimit.value,
|
||||
},
|
||||
};
|
||||
await store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToCreatePolicy = () => {
|
||||
const accountId = route.params.accountId;
|
||||
router.push({
|
||||
name: 'agent_assignment_policy_create',
|
||||
params: { accountId },
|
||||
query: { inboxId: props.inbox.id },
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToAssignmentPolicyEdit = () => {
|
||||
if (!assignmentPolicy.value?.id) return;
|
||||
const accountId = route.params.accountId;
|
||||
router.push({
|
||||
name: 'agent_assignment_policy_edit',
|
||||
params: { accountId, id: assignmentPolicy.value.id },
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToBilling = () => {
|
||||
const accountId = route.params.accountId;
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId },
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDeletePolicy = () => {
|
||||
showDeleteConfirmModal.value = true;
|
||||
};
|
||||
|
||||
const cancelDeletePolicy = () => {
|
||||
showDeleteConfirmModal.value = false;
|
||||
};
|
||||
|
||||
const deleteAssignmentPolicy = async () => {
|
||||
if (isDeletingPolicy.value) return;
|
||||
isDeletingPolicy.value = true;
|
||||
try {
|
||||
await assignmentPoliciesAPI.removeInboxPolicy(props.inbox.id);
|
||||
assignmentPolicy.value = null;
|
||||
showDeleteConfirmModal.value = false;
|
||||
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_ERROR'));
|
||||
} finally {
|
||||
isDeletingPolicy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setDefaults = () => {
|
||||
enableAutoAssignment.value = props.inbox.enable_auto_assignment;
|
||||
maxAssignmentLimit.value =
|
||||
props.inbox.auto_assignment_config?.max_assignment_limit || null;
|
||||
fetchAttachedAgents();
|
||||
if (showAdvancedAssignmentUI.value) {
|
||||
fetchAssignmentPolicy();
|
||||
fetchAvailablePolicies();
|
||||
}
|
||||
};
|
||||
|
||||
// Watch only inbox.id to avoid unnecessary refetches when other properties change
|
||||
watch(() => props.inbox.id, setDefaults);
|
||||
|
||||
onMounted(() => {
|
||||
setDefaults();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -138,7 +337,6 @@ export default {
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
@select="v$.selectedAgents.$touch"
|
||||
/>
|
||||
|
||||
<NextButton
|
||||
@@ -152,44 +350,325 @@ export default {
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
|
||||
>
|
||||
<label class="w-3/4 settings-item">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="enableAutoAssignment"
|
||||
<!-- New UI for assignment_v2 -->
|
||||
<template v-if="hasAssignmentV2">
|
||||
<div class="flex items-start gap-3">
|
||||
<Switch
|
||||
v-model="enableAutoAssignment"
|
||||
type="checkbox"
|
||||
@change="handleEnableAutoAssignment"
|
||||
class="flex-shrink-0 mt-0.5"
|
||||
@change="handleToggleAutoAssignment"
|
||||
/>
|
||||
<label for="enableAutoAssignment">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
|
||||
</label>
|
||||
<div class="flex-grow">
|
||||
<label class="text-sm text-n-slate-12 font-medium mb-1">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT') }}
|
||||
</label>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
|
||||
</p>
|
||||
</label>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="enableAutoAssignment" class="mt-6">
|
||||
<!-- Policy Card - When policy is attached -->
|
||||
<div
|
||||
v-if="showAdvancedAssignmentUI && assignmentPolicy"
|
||||
class="p-4 rounded-xl outline-1 outline-n-weak outline bg-n-solid-1 dark:bg-n-slate-1"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 size-12 rounded-xl bg-n-slate-3 flex items-center justify-center"
|
||||
>
|
||||
<span class="i-lucide-zap text-xl text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="text-base font-medium text-n-slate-12 mb-1">
|
||||
{{ assignmentPolicy.name }}
|
||||
</span>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.POLICY_LABEL') }}
|
||||
</p>
|
||||
</div>
|
||||
<NextButton
|
||||
icon="i-lucide-trash-2"
|
||||
ghost
|
||||
ruby
|
||||
sm
|
||||
@click="confirmDeletePolicy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
|
||||
<woot-input
|
||||
v-model="maxAssignmentLimit"
|
||||
type="number"
|
||||
:class="{ error: v$.maxAssignmentLimit.$error }"
|
||||
:error="maxAssignmentLimitErrors"
|
||||
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
|
||||
@blur="v$.maxAssignmentLimit.$touch"
|
||||
/>
|
||||
<ul class="space-y-2 mb-6">
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ assignmentOrderLabel }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ assignmentMethodLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
|
||||
</p>
|
||||
<div class="w-full h-px my-4 bg-n-weak" />
|
||||
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:disabled="v$.maxAssignmentLimit.$invalid"
|
||||
@click="updateInbox"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.ASSIGNMENT.CUSTOMIZE_POLICY')"
|
||||
icon="i-lucide-arrow-right"
|
||||
trailing-icon
|
||||
link
|
||||
class="mb-2"
|
||||
@click="navigateToAssignmentPolicyEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default Policy - When no custom policy attached but feature enabled -->
|
||||
<div
|
||||
v-else-if="
|
||||
showAdvancedAssignmentUI &&
|
||||
!assignmentPolicy &&
|
||||
!isLoadingPolicy
|
||||
"
|
||||
class="rounded-xl outline-1 outline-n-weak outline"
|
||||
>
|
||||
<!-- Default Policy Header -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
|
||||
>
|
||||
<i class="i-lucide-zap text-xl text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h4 class="text-base font-medium text-n-slate-12 mb-1">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_LINKED') }}
|
||||
</h4>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{
|
||||
$t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_DESCRIPTION')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mt-5 flex items-center gap-3">
|
||||
<div
|
||||
v-if="!isLoadingPolicies && availablePolicies.length > 0"
|
||||
v-on-click-outside="closePolicyDropdown"
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-n-brand hover:bg-n-brand/90 rounded-lg transition-colors"
|
||||
@click="togglePolicyDropdown"
|
||||
>
|
||||
<i class="i-lucide-link text-sm" />
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.LINK_EXISTING_POLICY') }}
|
||||
<i
|
||||
class="i-lucide-chevron-down text-sm transition-transform"
|
||||
:class="{ 'rotate-180': showPolicyDropdown }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="showPolicyDropdown"
|
||||
class="top-full left-0 mt-2 min-w-72"
|
||||
:menu-items="policyMenuItems"
|
||||
:is-searching="isLoadingPolicies"
|
||||
@action="handlePolicyMenuAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-n-slate-12 bg-n-slate-3 dark:bg-n-slate-4 hover:bg-n-slate-4 dark:hover:bg-n-slate-5 rounded-lg transition-colors"
|
||||
@click="navigateToCreatePolicy"
|
||||
>
|
||||
<i class="i-lucide-plus text-sm" />
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.CREATE_NEW_POLICY') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default Rules Info -->
|
||||
<div class="px-4 py-4 border-t border-n-weak bg-n-slate-2">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="i-lucide-info text-base text-n-slate-10 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-sm text-n-slate-11 mb-2">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.CURRENT_BEHAVIOR') }}
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default Rules Card - Feature not enabled (no advanced_assignment) -->
|
||||
<div
|
||||
v-else-if="!showAdvancedAssignmentUI"
|
||||
class="p-4 rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
|
||||
>
|
||||
<i class="i-lucide-zap text-xl text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h4 class="text-base font-medium text-n-slate-12 mb-1">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_TITLE') }}
|
||||
</h4>
|
||||
<p class="text-sm text-n-slate-11 mb-4">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_DESCRIPTION') }}
|
||||
</p>
|
||||
|
||||
<ul class="space-y-2 mb-6">
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="w-full h-px bg-n-weak my-4" />
|
||||
|
||||
<!-- Upgrade prompt when advanced_assignment is not enabled -->
|
||||
<div v-if="!hasAdvancedAssignment">
|
||||
<p class="text-sm text-n-slate-11 mb-1">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.UPGRADE_PROMPT') }}
|
||||
</p>
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.ASSIGNMENT.UPGRADE_TO_BUSINESS')"
|
||||
icon="i-lucide-arrow-right"
|
||||
trailing-icon
|
||||
link
|
||||
@click="navigateToBilling"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- Old UI for non-assignment_v2 -->
|
||||
<template v-else>
|
||||
<label class="w-3/4 settings-item">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="enableAutoAssignment"
|
||||
v-model="enableAutoAssignment"
|
||||
type="checkbox"
|
||||
@change="handleToggleAutoAssignment"
|
||||
/>
|
||||
<label for="enableAutoAssignment">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
|
||||
<woot-input
|
||||
v-model="maxAssignmentLimit"
|
||||
type="number"
|
||||
:class="{ error: v$.maxAssignmentLimit.$error }"
|
||||
:error="maxAssignmentLimitErrors"
|
||||
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
|
||||
@blur="v$.maxAssignmentLimit.$touch"
|
||||
/>
|
||||
|
||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
|
||||
</p>
|
||||
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:disabled="v$.maxAssignmentLimit.$invalid"
|
||||
@click="updateInbox"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsSection>
|
||||
|
||||
<woot-modal
|
||||
v-if="showDeleteConfirmModal"
|
||||
:show="showDeleteConfirmModal"
|
||||
:on-close="cancelDeletePolicy"
|
||||
>
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_TITLE') }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11 mb-6 ml-13">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_MESSAGE') }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<NextButton
|
||||
color="slate"
|
||||
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CANCEL')"
|
||||
@click="cancelDeletePolicy"
|
||||
/>
|
||||
<NextButton
|
||||
color="ruby"
|
||||
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CONFIRM_DELETE')"
|
||||
:is-loading="isDeletingPolicy"
|
||||
@click="deleteAssignmentPolicy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ class AutoAssignment::AssignmentService
|
||||
|
||||
def perform_bulk_assignment(limit: 100)
|
||||
return 0 unless inbox.auto_assignment_v2_enabled?
|
||||
return 0 unless inbox.enable_auto_assignment?
|
||||
|
||||
assigned_count = 0
|
||||
|
||||
@@ -32,7 +33,9 @@ class AutoAssignment::AssignmentService
|
||||
def unassigned_conversations(limit)
|
||||
scope = inbox.conversations.unassigned.open
|
||||
|
||||
scope = if assignment_config['conversation_priority'].to_s == 'longest_waiting'
|
||||
# Apply conversation priority using assignment policy if available
|
||||
policy = inbox.assignment_policy
|
||||
scope = if policy&.longest_waiting?
|
||||
scope.reorder(last_activity_at: :asc, created_at: :asc)
|
||||
else
|
||||
scope.reorder(created_at: :asc)
|
||||
@@ -81,10 +84,6 @@ class AutoAssignment::AssignmentService
|
||||
def round_robin_selector
|
||||
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
|
||||
end
|
||||
|
||||
def assignment_config
|
||||
@assignment_config ||= inbox.auto_assignment_config || {}
|
||||
end
|
||||
end
|
||||
|
||||
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')
|
||||
|
||||
@@ -8,8 +8,6 @@ class AutoAssignment::RateLimiter
|
||||
end
|
||||
|
||||
def track_assignment(conversation)
|
||||
return unless enabled?
|
||||
|
||||
assignment_key = build_assignment_key(conversation.id)
|
||||
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
|
||||
end
|
||||
@@ -24,11 +22,11 @@ class AutoAssignment::RateLimiter
|
||||
private
|
||||
|
||||
def enabled?
|
||||
limit.present? && limit.positive?
|
||||
config.present? && limit.positive?
|
||||
end
|
||||
|
||||
def limit
|
||||
config&.fair_distribution_limit&.to_i || Math
|
||||
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY
|
||||
end
|
||||
|
||||
def window
|
||||
|
||||
@@ -241,3 +241,7 @@
|
||||
display_name: Required Conversation Attributes
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: advanced_assignment
|
||||
display_name: Advanced Assignment
|
||||
enabled: false
|
||||
premium: true
|
||||
|
||||
@@ -3,6 +3,12 @@ module Enterprise::Account
|
||||
# this is a temporary method since current administrate doesn't support virtual attributes
|
||||
def manually_managed_features; end
|
||||
|
||||
# Auto-sync advanced_assignment with assignment_v2 when features are bulk-updated via admin UI
|
||||
def selected_feature_flags=(features)
|
||||
super
|
||||
sync_assignment_features
|
||||
end
|
||||
|
||||
def mark_for_deletion(reason = 'manual_deletion')
|
||||
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
|
||||
|
||||
@@ -31,4 +37,21 @@ module Enterprise::Account
|
||||
def saml_enabled?
|
||||
saml_settings&.saml_enabled? || false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_assignment_features
|
||||
if feature_enabled?('assignment_v2')
|
||||
# Enable advanced_assignment for Business/Enterprise plans
|
||||
send('feature_advanced_assignment=', true) if business_or_enterprise_plan?
|
||||
else
|
||||
# Disable advanced_assignment when assignment_v2 is disabled
|
||||
send('feature_advanced_assignment=', false)
|
||||
end
|
||||
end
|
||||
|
||||
def business_or_enterprise_plan?
|
||||
plan_name = custom_attributes['plan_name']
|
||||
%w[Business Enterprise].include?(plan_name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,8 @@ module Enterprise::AutoAssignment::AssignmentService
|
||||
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
|
||||
return nil if agents.empty?
|
||||
|
||||
selector = policy&.balanced? ? balanced_selector : round_robin_selector
|
||||
# Use balanced selector only if advanced_assignment feature is enabled
|
||||
selector = policy&.balanced? && account.feature_enabled?('advanced_assignment') ? balanced_selector : round_robin_selector
|
||||
selector.select_agent(agents)
|
||||
end
|
||||
|
||||
@@ -31,7 +32,7 @@ module Enterprise::AutoAssignment::AssignmentService
|
||||
end
|
||||
|
||||
def capacity_filtering_enabled?
|
||||
account.feature_enabled?('assignment_v2') &&
|
||||
account.feature_enabled?('advanced_assignment') &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
].freeze
|
||||
|
||||
# Additional features available starting with the Business plan
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes].freeze
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
|
||||
|
||||
# Additional features available only in the Enterprise plan
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
|
||||
@@ -16,8 +16,9 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
|
||||
# Link inbox to assignment policy
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
# Enable assignment_v2 (base) and advanced_assignment (premium) features
|
||||
account.enable_features('assignment_v2')
|
||||
account.save!
|
||||
|
||||
# Set agents as online
|
||||
OnlineStatusTracker.update_presence(account.id, 'User', agent1.id)
|
||||
|
||||
@@ -52,8 +52,9 @@ RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
|
||||
agent_at_capacity.id.to_s => 'online'
|
||||
})
|
||||
|
||||
# Enable assignment_v2 feature
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
# Enable assignment_v2 (base) and advanced_assignment (premium) features
|
||||
account.enable_features('assignment_v2', 'advanced_assignment')
|
||||
account.save!
|
||||
|
||||
# Create existing assignments for agent_at_capacity (at limit)
|
||||
3.times do
|
||||
|
||||
@@ -14,13 +14,13 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
describe '#perform' do
|
||||
context 'when account has assignment_v2 feature enabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
account.enable_features('assignment_v2')
|
||||
account.save!
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account])
|
||||
end
|
||||
|
||||
context 'when inbox has auto_assignment_v2 enabled' do
|
||||
context 'when inbox has assignment policy or auto assignment enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(true)
|
||||
inbox_relation = instance_double(ActiveRecord::Relation)
|
||||
allow(account).to receive(:inboxes).and_return(inbox_relation)
|
||||
allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
|
||||
@@ -41,8 +41,8 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
policy2 = create(:assignment_policy, account: account2)
|
||||
create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2)
|
||||
|
||||
allow(account2).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
allow(inbox2).to receive(:auto_assignment_v2_enabled?).and_return(true)
|
||||
account2.enable_features('assignment_v2')
|
||||
account2.save!
|
||||
|
||||
inbox_relation2 = instance_double(ActiveRecord::Relation)
|
||||
allow(account2).to receive(:inboxes).and_return(inbox_relation2)
|
||||
@@ -58,9 +58,10 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox does not have auto_assignment_v2 enabled' do
|
||||
context 'when inbox does not have assignment policy or auto assignment enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(false)
|
||||
inbox.update!(enable_auto_assignment: false)
|
||||
InboxAssignmentPolicy.where(inbox: inbox).destroy_all
|
||||
end
|
||||
|
||||
it 'does not queue assignment job' do
|
||||
@@ -73,7 +74,6 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
|
||||
context 'when account does not have assignment_v2 feature enabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(false)
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account])
|
||||
end
|
||||
|
||||
@@ -90,11 +90,11 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
# Create multiple accounts
|
||||
5.times do |_i|
|
||||
acc = create(:account)
|
||||
acc.enable_features('assignment_v2')
|
||||
acc.save!
|
||||
inb = create(:inbox, account: acc, enable_auto_assignment: true)
|
||||
policy = create(:assignment_policy, account: acc)
|
||||
create(:inbox_assignment_policy, inbox: inb, assignment_policy: policy)
|
||||
allow(acc).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
allow(inb).to receive(:auto_assignment_v2_enabled?).and_return(true)
|
||||
|
||||
inbox_relation = instance_double(ActiveRecord::Relation)
|
||||
allow(acc).to receive(:inboxes).and_return(inbox_relation)
|
||||
|
||||
@@ -10,8 +10,9 @@ RSpec.describe AutoAssignment::AssignmentService do
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
# Enable assignment_v2 feature for the account
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
# Enable assignment_v2 feature for the account (basic assignment features)
|
||||
account.enable_features('assignment_v2')
|
||||
account.save!
|
||||
# Link inbox to assignment policy
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
|
||||
@@ -59,8 +59,9 @@ RSpec.describe AutoAssignment::RateLimiter do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(nil)
|
||||
end
|
||||
|
||||
it 'does not track the assignment' do
|
||||
expect(Redis::Alfred).not_to receive(:set)
|
||||
it 'still tracks the assignment with default window' do
|
||||
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
|
||||
expect(Redis::Alfred).to receive(:set).with(expected_key, conversation.id.to_s, ex: 24.hours.to_i)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user