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:
Tanmay Deep Sharma 2026-02-11 12:24:45 +05:30 committed by GitHub
parent 8f95fafff4
commit 7b512bd00e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1284 additions and 336 deletions

View File

@ -39,7 +39,6 @@ const policyA = withCount({
description: 'Distributes conversations evenly among available agents', description: 'Distributes conversations evenly among available agents',
assignmentOrder: 'round_robin', assignmentOrder: 'round_robin',
conversationPriority: 'high', conversationPriority: 'high',
enabled: true,
inboxes: [mockInboxes[0], mockInboxes[1]], inboxes: [mockInboxes[0], mockInboxes[1]],
isFetchingInboxes: false, isFetchingInboxes: false,
}); });
@ -50,7 +49,6 @@ const policyB = withCount({
description: 'Assigns based on capacity and workload', description: 'Assigns based on capacity and workload',
assignmentOrder: 'capacity_based', assignmentOrder: 'capacity_based',
conversationPriority: 'medium', conversationPriority: 'medium',
enabled: true,
inboxes: [mockInboxes[2], mockInboxes[3]], inboxes: [mockInboxes[2], mockInboxes[3]],
isFetchingInboxes: false, isFetchingInboxes: false,
}); });
@ -61,7 +59,6 @@ const emptyPolicy = withCount({
description: 'Policy with no assigned inboxes', description: 'Policy with no assigned inboxes',
assignmentOrder: 'manual', assignmentOrder: 'manual',
conversationPriority: 'low', conversationPriority: 'low',
enabled: false,
inboxes: [], inboxes: [],
isFetchingInboxes: false, isFetchingInboxes: false,
}); });

View File

@ -15,7 +15,6 @@ const props = defineProps({
assignmentOrder: { type: String, default: '' }, assignmentOrder: { type: String, default: '' },
conversationPriority: { type: String, default: '' }, conversationPriority: { type: String, default: '' },
assignedInboxCount: { type: Number, default: 0 }, assignedInboxCount: { type: Number, default: 0 },
enabled: { type: Boolean, default: false },
inboxes: { type: Array, default: () => [] }, inboxes: { type: Array, default: () => [] },
isFetchingInboxes: { type: Boolean, default: false }, isFetchingInboxes: { type: Boolean, default: false },
}); });
@ -65,22 +64,6 @@ const handleFetchInboxes = () => {
{{ name }} {{ name }}
</h3> </h3>
<div class="flex items-center gap-2"> <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 <CardPopover
:title=" :title="
t( t(

View File

@ -19,11 +19,15 @@ defineProps({
}, },
}); });
const emit = defineEmits(['delete']); const emit = defineEmits(['delete', 'navigate']);
const handleDelete = itemId => { const handleDelete = itemId => {
emit('delete', itemId); emit('delete', itemId);
}; };
const handleNavigate = item => {
emit('navigate', item);
};
</script> </script>
<template> <template>
@ -47,7 +51,11 @@ const handleDelete = itemId => {
:key="item.id" :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" 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 <Icon
v-if="item.icon" v-if="item.icon"
:icon="item.icon" :icon="item.icon"
@ -61,10 +69,16 @@ const handleDelete = itemId => {
:size="20" :size="20"
rounded-full 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 }} {{ item.name }}
</span> </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"> <div class="flex items-start gap-2 col-span-1">
<span <span

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.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', { const fairDistributionWindow = defineModel('fairDistributionWindow', {
type: Number, type: Number,
default: 3600, default: 3600,
@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
const windowUnit = ref(DURATION_UNITS.MINUTES); 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 detectUnit = minutes => {
const m = Number(minutes) || 0; const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES; if (m === 0) return DURATION_UNITS.MINUTES;
@ -34,7 +48,7 @@ const detectUnit = minutes => {
}; };
onMounted(() => { onMounted(() => {
windowUnit.value = detectUnit(fairDistributionWindow.value); windowUnit.value = detectUnit(windowInMinutes.value);
}); });
</script> </script>
@ -73,9 +87,9 @@ onMounted(() => {
<div <div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110" 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 <DurationInput
v-model:model-value="fairDistributionWindow" v-model:model-value="windowInMinutes"
v-model:unit="windowUnit" v-model:unit="windowUnit"
:min="10" :min="10"
:max="1438560" :max="1438560"

View File

@ -1,4 +1,6 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
id: { id: {
type: String, type: String,
@ -16,12 +18,22 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
disabled: {
type: Boolean,
default: false,
},
disabledMessage: {
type: String,
default: '',
},
}); });
const emit = defineEmits(['select']); const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => { const handleChange = () => {
if (!props.isActive) { if (!props.isActive && !props.disabled) {
emit('select', props.id); emit('select', props.id);
} }
}; };
@ -29,9 +41,11 @@ const handleChange = () => {
<template> <template>
<div <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="[ :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" @click="handleChange"
> >
@ -41,6 +55,7 @@ const handleChange = () => {
:checked="isActive" :checked="isActive"
:value="id" :value="id"
:name="id" :name="id"
:disabled="disabled"
type="radio" type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0" class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange" @change="handleChange"
@ -49,11 +64,23 @@ const handleChange = () => {
<!-- Content --> <!-- Content -->
<div class="flex flex-col gap-3 items-start"> <div class="flex flex-col gap-3 items-start">
<h3 class="text-sm font-medium text-n-slate-12"> <div class="flex items-center gap-2">
{{ label }} <h3 class="text-sm font-medium text-n-slate-12">
</h3> {{ 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"> <p class="text-sm text-n-slate-11">
{{ description }} {{ disabled && disabledMessage ? disabledMessage : description }}
</p> </p>
</div> </div>
</div> </div>

View File

@ -6,7 +6,6 @@ const policyName = ref('Round Robin Policy');
const description = ref( const description = ref(
'Distributes conversations evenly among available agents' 'Distributes conversations evenly among available agents'
); );
const enabled = ref(true);
</script> </script>
<template> <template>
@ -19,13 +18,10 @@ const enabled = ref(true);
<BaseInfo <BaseInfo
v-model:policy-name="policyName" v-model:policy-name="policyName"
v-model:description="description" v-model:description="description"
v-model:enabled="enabled"
name-label="Policy Name" name-label="Policy Name"
name-placeholder="Enter policy name" name-placeholder="Enter policy name"
description-label="Description" description-label="Description"
description-placeholder="Enter policy description" description-placeholder="Enter policy description"
status-label="Status"
status-placeholder="Active"
/> />
</div> </div>
</Variant> </Variant>

View File

@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts'; import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components'; import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useWindowSize, useEventListener } from '@vueuse/core'; import { useWindowSize, useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
@ -50,6 +51,18 @@ const isRTL = useMapGetter('accounts/isRTL');
const { width: windowWidth } = useWindowSize(); const { width: windowWidth } = useWindowSize();
const isMobile = computed(() => windowWidth.value < 768); 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 => { const toggleShortcutModalFn = show => {
if (show) { if (show) {
emit('openKeyShortcutModal'); emit('openKeyShortcutModal');
@ -584,12 +597,16 @@ const menuItems = computed(() => {
icon: 'i-lucide-users', icon: 'i-lucide-users',
to: accountScopedRoute('settings_teams_list'), to: accountScopedRoute('settings_teams_list'),
}, },
{ ...(hasAdvancedAssignment.value
name: 'Settings Agent Assignment', ? [
label: t('SIDEBAR.AGENT_ASSIGNMENT'), {
icon: 'i-lucide-user-cog', name: 'Settings Agent Assignment',
to: accountScopedRoute('assignment_policy_index'), label: t('SIDEBAR.AGENT_ASSIGNMENT'),
}, icon: 'i-lucide-user-cog',
to: accountScopedRoute('assignment_policy_index'),
},
]
: []),
{ {
name: 'Settings Inboxes', name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'), label: t('SIDEBAR.INBOXES'),

View File

@ -2,6 +2,7 @@ export const FEATURE_FLAGS = {
AGENT_BOTS: 'agent_bots', AGENT_BOTS: 'agent_bots',
AGENT_MANAGEMENT: 'agent_management', AGENT_MANAGEMENT: 'agent_management',
ASSIGNMENT_V2: 'assignment_v2', ASSIGNMENT_V2: 'assignment_v2',
ADVANCED_ASSIGNMENT: 'advanced_assignment',
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations', AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
AUTOMATIONS: 'automations', AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns', CAMPAIGNS: 'campaigns',
@ -56,4 +57,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.HELP_CENTER, FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.SAML, FEATURE_FLAGS.SAML,
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES, FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
]; ];

View File

@ -766,6 +766,53 @@
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0", "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" "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": { "FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize", "TITLE": "Reauthorize",
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services", "SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",

View File

@ -694,7 +694,8 @@
"CREATE_BUTTON": "Create policy", "CREATE_BUTTON": "Create policy",
"API": { "API": {
"SUCCESS_MESSAGE": "Assignment policy created successfully", "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": { "EDIT": {
@ -708,6 +709,12 @@
"CONFIRM_BUTTON_LABEL": "Continue", "CONFIRM_BUTTON_LABEL": "Continue",
"CANCEL_BUTTON_LABEL": "Cancel" "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": { "API": {
"SUCCESS_MESSAGE": "Assignment policy updated successfully", "SUCCESS_MESSAGE": "Assignment policy updated successfully",
"ERROR_MESSAGE": "Failed to update assignment policy" "ERROR_MESSAGE": "Failed to update assignment policy"
@ -746,7 +753,9 @@
}, },
"BALANCED": { "BALANCED": {
"LABEL": "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": { "ASSIGNMENT_PRIORITY": {
@ -832,6 +841,20 @@
"SUCCESS_MESSAGE": "Agent removed from policy successfully", "SUCCESS_MESSAGE": "Agent removed from policy successfully",
"ERROR_MESSAGE": "Failed to remove agent from policy" "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": { "FORM": {

View File

@ -1,54 +1,81 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; 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 BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue'; import SettingsLayout from '../SettingsLayout.vue';
import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue'; import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue';
const router = useRouter(); const router = useRouter();
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const agentAssignments = computed(() => [ const accountId = computed(() => Number(route.params.accountId));
{ const isFeatureEnabledonAccount = useMapGetter(
key: 'agent_assignment_policy_index', 'accounts/isFeatureEnabledonAccount'
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'), );
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
features: [ const agentAssignments = computed(() => {
{ const assignments = [
icon: 'i-lucide-circle-fading-arrow-up', {
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'), key: 'agent_assignment_policy_index',
}, title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
{ description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
icon: 'i-lucide-scale', features: [
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'), {
}, icon: 'i-lucide-circle-fading-arrow-up',
{ label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
icon: 'i-lucide-inbox', },
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'), {
}, icon: 'i-lucide-scale',
], label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
}, },
{ {
key: 'agent_capacity_policy_index', icon: 'i-lucide-inbox',
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'), label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
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'),
}, // Only show Agent Capacity if BOTH assignment_v2 AND advanced_assignment are enabled
{ // advanced_assignment identifies premium users
icon: 'i-lucide-circle-minus', const hasAssignmentV2 = isFeatureEnabledonAccount.value(
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'), accountId.value,
}, 'assignment_v2'
{ );
icon: 'i-lucide-users-round', const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'), 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 => { const handleClick = key => {
router.push({ name: key }); router.push({ name: key });

View File

@ -62,7 +62,7 @@ export default {
name: 'agent_capacity_policy_index', name: 'agent_capacity_policy_index',
component: AgentCapacityIndex, component: AgentCapacityIndex,
meta: { meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2, featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
permissions: ['administrator'], permissions: ['administrator'],
}, },
}, },
@ -71,7 +71,7 @@ export default {
name: 'agent_capacity_policy_create', name: 'agent_capacity_policy_create',
component: AgentCapacityCreate, component: AgentCapacityCreate,
meta: { meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2, featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
permissions: ['administrator'], permissions: ['administrator'],
}, },
}, },
@ -80,7 +80,7 @@ export default {
name: 'agent_capacity_policy_edit', name: 'agent_capacity_policy_edit',
component: AgentCapacityEdit, component: AgentCapacityEdit,
meta: { meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2, featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
permissions: ['administrator'], permissions: ['administrator'],
}, },
}, },

View File

@ -2,13 +2,14 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue'; import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue'; import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue'; import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
const route = useRoute();
const router = useRouter(); const router = useRouter();
const store = useStore(); const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
@ -16,20 +17,50 @@ const { t } = useI18n();
const formRef = ref(null); const formRef = ref(null);
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags'); const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const breadcrumbItems = computed(() => [ const inboxIdFromQuery = computed(() => {
{ const id = route.query.inboxId;
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'), return id ? Number(id) : null;
routeName: 'agent_assignment_policy_index', });
},
{ const breadcrumbItems = computed(() => {
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'), 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 => { const handleBreadcrumbClick = item => {
router.push({ if (item.params) {
name: item.routeName, 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 => { const handleSubmit = async formState => {
@ -45,6 +76,8 @@ const handleSubmit = async formState => {
params: { params: {
id: policy.id, id: policy.id,
}, },
// Pass inboxId to edit page to show link prompt
query: inboxIdFromQuery.value ? { inboxId: inboxIdFromQuery.value } : {},
}); });
} catch (error) { } catch (error) {
useAlert( useAlert(

View File

@ -14,6 +14,7 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue'; import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.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 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'; 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 // Store the policy linked to the inbox when adding a new inbox
const inboxLinkedPolicy = ref(null); const inboxLinkedPolicy = ref(null);
const breadcrumbItems = computed(() => [ // Inbox linking prompt from create flow
{ const inboxIdFromQuery = computed(() => {
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`), const id = route.query.inboxId;
routeName: 'agent_assignment_policy_index', return id ? Number(id) : null;
}, });
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
]); 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 => const buildInboxList = allInboxes =>
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({ allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
@ -66,22 +100,48 @@ const inboxList = computed(() =>
const formData = computed(() => ({ const formData = computed(() => ({
name: selectedPolicy.value?.name || '', name: selectedPolicy.value?.name || '',
description: selectedPolicy.value?.description || '', description: selectedPolicy.value?.description || '',
enabled: selectedPolicy.value?.enabled || false, enabled: true,
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN, assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
conversationPriority: conversationPriority:
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED, selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 10, fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 60, fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
})); }));
const handleDeleteInbox = inboxId => const handleDeleteInbox = async inboxId => {
store.dispatch('assignmentPolicies/removeInboxPolicy', { try {
policyId: selectedPolicy.value?.id, await store.dispatch('assignmentPolicies/removeInboxPolicy', {
inboxId, 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 }) => const handleBreadcrumbClick = ({ routeName, params }) => {
router.push({ name: routeName }); 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) => { const setInboxPolicy = async (inboxId, policyId) => {
try { try {
@ -122,6 +182,26 @@ const handleAddInbox = async inbox => {
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id); 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 handleConfirmAddInbox = async inboxId => {
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id); const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
@ -155,6 +235,11 @@ const handleSubmit = async formState => {
const fetchPolicyData = async () => { const fetchPolicyData = async () => {
if (!routeId.value) return; 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 // Fetch policy if not available
if (!selectedPolicy.value?.id) if (!selectedPolicy.value?.id)
await store.dispatch('assignmentPolicies/show', routeId.value); await store.dispatch('assignmentPolicies/show', routeId.value);
@ -186,6 +271,7 @@ watch(routeId, fetchPolicyData, { immediate: true });
@submit="handleSubmit" @submit="handleSubmit"
@add-inbox="handleAddInbox" @add-inbox="handleAddInbox"
@delete-inbox="handleDeleteInbox" @delete-inbox="handleDeleteInbox"
@navigate-to-inbox="handleNavigateToInbox"
/> />
</template> </template>
@ -193,5 +279,12 @@ watch(routeId, fetchPolicyData, { immediate: true });
ref="confirmInboxDialogRef" ref="confirmInboxDialogRef"
@add="handleConfirmAddInbox" @add="handleConfirmAddInbox"
/> />
<InboxLinkDialog
:inbox="suggestedInbox"
:is-linking="isLinkingInbox"
@link="handleLinkSuggestedInbox"
@dismiss="dismissInboxLinkPrompt"
/>
</SettingsLayout> </SettingsLayout>
</template> </template>

View File

@ -92,43 +92,68 @@ const formData = computed(() => ({
const handleBreadcrumbClick = ({ routeName }) => const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName }); router.push({ name: routeName });
const handleDeleteUser = agentId => { const handleDeleteUser = async agentId => {
store.dispatch('agentCapacityPolicies/removeUser', { try {
policyId: selectedPolicyId.value, await store.dispatch('agentCapacityPolicies/removeUser', {
userId: agentId, 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 => { const handleAddUser = async agent => {
store.dispatch('agentCapacityPolicies/addUser', { try {
policyId: selectedPolicyId.value, await store.dispatch('agentCapacityPolicies/addUser', {
userData: { id: agent.id, capacity: 20 }, 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 => { const handleDeleteInboxLimit = async limitId => {
store.dispatch('agentCapacityPolicies/deleteInboxLimit', { try {
policyId: selectedPolicyId.value, await store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
limitId, 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 => { const handleAddInboxLimit = async limit => {
store.dispatch('agentCapacityPolicies/createInboxLimit', { try {
policyId: selectedPolicyId.value, await store.dispatch('agentCapacityPolicies/createInboxLimit', {
limitData: { policyId: selectedPolicyId.value,
inboxId: limit.inboxId, limitData: {
conversationLimit: limit.conversationLimit, 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 => { const handleLimitChange = async limit => {
store.dispatch('agentCapacityPolicies/updateInboxLimit', { try {
policyId: selectedPolicyId.value, await store.dispatch('agentCapacityPolicies/updateInboxLimit', {
limitId: limit.id, policyId: selectedPolicyId.value,
limitData: { conversationLimit: limit.conversationLimit }, 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 => { const handleSubmit = async formState => {

View File

@ -1,7 +1,8 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; 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 BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue'; import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue'; import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
@ -23,7 +24,6 @@ const props = defineProps({
default: () => ({ default: () => ({
name: '', name: '',
description: '', description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN, assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED, conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT, fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@ -61,18 +61,24 @@ const emit = defineEmits([
'submit', 'submit',
'addInbox', 'addInbox',
'deleteInbox', 'deleteInbox',
'navigateToInbox',
'validationChange', 'validationChange',
]); ]);
const { t } = useI18n(); 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 BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const state = reactive({ const state = reactive({
name: '', name: '',
description: '', description: '',
enabled: false, enabled: true,
assignmentOrder: ROUND_ROBIN, assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED, conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT, fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@ -83,20 +89,42 @@ const validationState = ref({
isValid: false, isValid: false,
}); });
const createOption = (type, key, stateKey) => ({ const createOption = (
type,
key,
stateKey,
disabled = false,
disabledMessage = ''
) => ({
key, key,
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`), label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`), description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
isActive: state[stateKey] === key, isActive: state[stateKey] === key,
disabled,
disabledMessage,
}); });
const assignmentOrderOptions = computed(() => { const assignmentOrderOptions = computed(() => {
const options = OPTIONS.ORDER.filter( const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
key => isEnterprise || key !== 'balanced' accountId.value,
); 'advanced_assignment'
return options.map(key =>
createOption('ASSIGNMENT_ORDER', key, 'assignmentOrder')
); );
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(() => const assignmentPriorityOptions = computed(() =>
@ -131,7 +159,7 @@ const resetForm = () => {
Object.assign(state, { Object.assign(state, {
name: '', name: '',
description: '', description: '',
enabled: false, enabled: true,
assignmentOrder: ROUND_ROBIN, assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED, conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT, fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@ -162,15 +190,10 @@ defineExpose({
<BaseInfo <BaseInfo
v-model:policy-name="state.name" v-model:policy-name="state.name"
v-model:description="state.description" v-model:description="state.description"
v-model:enabled="state.enabled"
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)" :name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)" :name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)" :description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)" :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" @validation-change="handleValidationChange"
/> />
@ -193,6 +216,8 @@ defineExpose({
:label="option.label" :label="option.label"
:description="option.description" :description="option.description"
:is-active="option.isActive" :is-active="option.isActive"
:disabled="option.disabled"
:disabled-message="option.disabledMessage"
@select="state[section.key] = $event" @select="state[section.key] = $event"
/> />
</div> </div>
@ -251,6 +276,7 @@ defineExpose({
:is-fetching="isInboxLoading" :is-fetching="isInboxLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)" :empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
@delete="$emit('deleteInbox', $event)" @delete="$emit('deleteInbox', $event)"
@navigate="$emit('navigateToInbox', $event)"
/> />
</div> </div>
</form> </form>

View File

@ -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>

View File

@ -1,122 +1,321 @@
<script> <script setup>
import { mapGetters } from 'vuex'; 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 { useVuelidate } from '@vuelidate/core';
import { minValue } from '@vuelidate/validators'; import { minValue } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig'; import { useConfig } from 'dashboard/composables/useConfig';
import SettingsSection from '../../../../../components/SettingsSection.vue'; import SettingsSection from '../../../../../components/SettingsSection.vue';
import NextButton from 'dashboard/components-next/button/Button.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 { const props = defineProps({
components: { inbox: {
SettingsSection, type: Object,
NextButton, default: () => ({}),
}, },
props: { });
inbox: {
type: Object,
default: () => ({}),
},
},
setup() {
const { isEnterprise } = useConfig();
return { v$: useVuelidate(), isEnterprise }; const store = useStore();
}, const route = useRoute();
data() { const router = useRouter();
return { const { t } = useI18n();
selectedAgents: [], const { isEnterprise } = useConfig();
isAgentListUpdating: false,
enableAutoAssignment: false, const selectedAgents = ref([]);
maxAssignmentLimit: null, const isAgentListUpdating = ref(false);
}; const enableAutoAssignment = ref(false);
}, const maxAssignmentLimit = ref(null);
computed: { const assignmentPolicy = ref(null);
...mapGetters({ const isLoadingPolicy = ref(false);
agentList: 'agents/getAgents', const isDeletingPolicy = ref(false);
}), const showDeleteConfirmModal = ref(false);
maxAssignmentLimitErrors() { const availablePolicies = ref([]);
if (this.v$.maxAssignmentLimit.$error) { const isLoadingPolicies = ref(false);
return this.$t( const showPolicyDropdown = ref(false);
'INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR' const isLinkingPolicy = ref(false);
);
} const agentList = computed(() => store.getters['agents/getAgents']);
return '';
}, const isFeatureEnabled = feature => {
}, const accountId = Number(route.params.accountId);
watch: { return store.getters['accounts/isFeatureEnabledonAccount'](
inbox() { accountId,
this.setDefaults(); feature
}, );
}, };
mounted() {
this.setDefaults(); const hasAdvancedAssignment = computed(() => {
}, return isFeatureEnabled('advanced_assignment');
methods: { });
setDefaults() {
this.enableAutoAssignment = this.inbox.enable_auto_assignment; const hasAssignmentV2 = computed(() => {
this.maxAssignmentLimit = return isFeatureEnabled('assignment_v2');
this.inbox?.auto_assignment_config?.max_assignment_limit || null; });
this.fetchAttachedAgents();
}, const showAdvancedAssignmentUI = computed(() => {
async fetchAttachedAgents() { return hasAdvancedAssignment.value && hasAssignmentV2.value;
try { });
const response = await this.$store.dispatch('inboxMembers/get', {
inboxId: this.inbox.id, const assignmentOrderLabel = computed(() => {
}); if (!assignmentPolicy.value) return '';
const { const priority = assignmentPolicy.value.conversation_priority;
data: { payload: inboxMembers }, if (priority === 'earliest_created') {
} = response; return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.EARLIEST_CREATED');
this.selectedAgents = inboxMembers; }
} catch (error) { if (priority === 'longest_waiting') {
// Handle error return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.LONGEST_WAITING');
} }
}, return priority;
handleEnableAutoAssignment() { });
this.updateInbox();
}, const assignmentMethodLabel = computed(() => {
async updateAgents() { if (!assignmentPolicy.value) return '';
const agentList = this.selectedAgents.map(el => el.id); const order = assignmentPolicy.value.assignment_order;
this.isAgentListUpdating = true; if (order === 'round_robin') {
try { return t('INBOX_MGMT.ASSIGNMENT.METHOD.ROUND_ROBIN');
await this.$store.dispatch('inboxMembers/create', { }
inboxId: this.inbox.id, if (order === 'balanced') {
agentList, return t('INBOX_MGMT.ASSIGNMENT.METHOD.BALANCED');
}); }
useAlert(this.$t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE')); return order;
} catch (error) { });
useAlert(this.$t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
} // Vuelidate validation rules
this.isAgentListUpdating = false; const rules = {
}, maxAssignmentLimit: {
async updateInbox() { minValue: minValue(1),
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 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> </script>
<template> <template>
@ -138,7 +337,6 @@ export default {
selected-label selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" :select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')" :deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
@select="v$.selectedAgents.$touch"
/> />
<NextButton <NextButton
@ -152,44 +350,325 @@ export default {
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')" :title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')" :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
> >
<label class="w-3/4 settings-item"> <!-- New UI for assignment_v2 -->
<div class="flex items-center gap-2"> <template v-if="hasAssignmentV2">
<input <div class="flex items-start gap-3">
id="enableAutoAssignment" <Switch
v-model="enableAutoAssignment" v-model="enableAutoAssignment"
type="checkbox" class="flex-shrink-0 mt-0.5"
@change="handleEnableAutoAssignment" @change="handleToggleAutoAssignment"
/> />
<label for="enableAutoAssignment"> <div class="flex-grow">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }} <label class="text-sm text-n-slate-12 font-medium mb-1">
</label> {{ $t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT') }}
</label>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION') }}
</p>
</div>
</div> </div>
<p class="pb-1 text-sm not-italic text-n-slate-11"> <Transition
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }} enter-active-class="transition-all duration-200 ease-out"
</p> enter-from-class="opacity-0 -translate-y-2"
</label> 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"> <ul class="space-y-2 mb-6">
<woot-input <li class="flex items-center gap-2">
v-model="maxAssignmentLimit" <span
type="number" class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
:class="{ error: v$.maxAssignmentLimit.$error }" />
:error="maxAssignmentLimitErrors" <span class="text-sm text-n-slate-12">
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')" {{ assignmentOrderLabel }}
@blur="v$.maxAssignmentLimit.$touch" </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"> <div class="w-full h-px my-4 bg-n-weak" />
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
</p>
<NextButton <NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')" :label="$t('INBOX_MGMT.ASSIGNMENT.CUSTOMIZE_POLICY')"
:disabled="v$.maxAssignmentLimit.$invalid" icon="i-lucide-arrow-right"
@click="updateInbox" trailing-icon
/> link
</div> 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> </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> </div>
</template> </template>

View File

@ -3,6 +3,7 @@ class AutoAssignment::AssignmentService
def perform_bulk_assignment(limit: 100) def perform_bulk_assignment(limit: 100)
return 0 unless inbox.auto_assignment_v2_enabled? return 0 unless inbox.auto_assignment_v2_enabled?
return 0 unless inbox.enable_auto_assignment?
assigned_count = 0 assigned_count = 0
@ -32,7 +33,9 @@ class AutoAssignment::AssignmentService
def unassigned_conversations(limit) def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open 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) scope.reorder(last_activity_at: :asc, created_at: :asc)
else else
scope.reorder(created_at: :asc) scope.reorder(created_at: :asc)
@ -81,10 +84,6 @@ class AutoAssignment::AssignmentService
def round_robin_selector def round_robin_selector
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox) @round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
end end
def assignment_config
@assignment_config ||= inbox.auto_assignment_config || {}
end
end end
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService') AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')

View File

@ -8,8 +8,6 @@ class AutoAssignment::RateLimiter
end end
def track_assignment(conversation) def track_assignment(conversation)
return unless enabled?
assignment_key = build_assignment_key(conversation.id) assignment_key = build_assignment_key(conversation.id)
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window) Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
end end
@ -24,11 +22,11 @@ class AutoAssignment::RateLimiter
private private
def enabled? def enabled?
limit.present? && limit.positive? config.present? && limit.positive?
end end
def limit def limit
config&.fair_distribution_limit&.to_i || Math config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY
end end
def window def window

View File

@ -241,3 +241,7 @@
display_name: Required Conversation Attributes display_name: Required Conversation Attributes
enabled: false enabled: false
premium: true premium: true
- name: advanced_assignment
display_name: Advanced Assignment
enabled: false
premium: true

View File

@ -3,6 +3,12 @@ module Enterprise::Account
# this is a temporary method since current administrate doesn't support virtual attributes # this is a temporary method since current administrate doesn't support virtual attributes
def manually_managed_features; end 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') def mark_for_deletion(reason = 'manual_deletion')
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity' reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
@ -31,4 +37,21 @@ module Enterprise::Account
def saml_enabled? def saml_enabled?
saml_settings&.saml_enabled? || false saml_settings&.saml_enabled? || false
end 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 end

View File

@ -19,7 +19,8 @@ module Enterprise::AutoAssignment::AssignmentService
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled? agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
return nil if agents.empty? 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) selector.select_agent(agents)
end end
@ -31,7 +32,7 @@ module Enterprise::AutoAssignment::AssignmentService
end end
def capacity_filtering_enabled? def capacity_filtering_enabled?
account.feature_enabled?('assignment_v2') && account.feature_enabled?('advanced_assignment') &&
account.account_users.joins(:agent_capacity_policy).exists? account.account_users.joins(:agent_capacity_policy).exists?
end end

View File

@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
].freeze ].freeze
# Additional features available starting with the Business plan # 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 # Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze

View File

@ -16,8 +16,9 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
# Link inbox to assignment policy # Link inbox to assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
allow(account).to receive(:feature_enabled?).and_return(false) # Enable assignment_v2 (base) and advanced_assignment (premium) features
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true) account.enable_features('assignment_v2')
account.save!
# Set agents as online # Set agents as online
OnlineStatusTracker.update_presence(account.id, 'User', agent1.id) OnlineStatusTracker.update_presence(account.id, 'User', agent1.id)

View File

@ -52,8 +52,9 @@ RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
agent_at_capacity.id.to_s => 'online' agent_at_capacity.id.to_s => 'online'
}) })
# Enable assignment_v2 feature # Enable assignment_v2 (base) and advanced_assignment (premium) features
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true) account.enable_features('assignment_v2', 'advanced_assignment')
account.save!
# Create existing assignments for agent_at_capacity (at limit) # Create existing assignments for agent_at_capacity (at limit)
3.times do 3.times do

View File

@ -14,13 +14,13 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
describe '#perform' do describe '#perform' do
context 'when account has assignment_v2 feature enabled' do context 'when account has assignment_v2 feature enabled' do
before 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]) allow(Account).to receive(:find_in_batches).and_yield([account])
end end
context 'when inbox has auto_assignment_v2 enabled' do context 'when inbox has assignment policy or auto assignment enabled' do
before do before do
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(true)
inbox_relation = instance_double(ActiveRecord::Relation) inbox_relation = instance_double(ActiveRecord::Relation)
allow(account).to receive(:inboxes).and_return(inbox_relation) allow(account).to receive(:inboxes).and_return(inbox_relation)
allow(inbox_relation).to receive(:joins).with(:assignment_policy).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) policy2 = create(:assignment_policy, account: account2)
create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2) create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2)
allow(account2).to receive(:feature_enabled?).with('assignment_v2').and_return(true) account2.enable_features('assignment_v2')
allow(inbox2).to receive(:auto_assignment_v2_enabled?).and_return(true) account2.save!
inbox_relation2 = instance_double(ActiveRecord::Relation) inbox_relation2 = instance_double(ActiveRecord::Relation)
allow(account2).to receive(:inboxes).and_return(inbox_relation2) allow(account2).to receive(:inboxes).and_return(inbox_relation2)
@ -58,9 +58,10 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
end end
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 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 end
it 'does not queue assignment job' do 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 context 'when account does not have assignment_v2 feature enabled' do
before 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]) allow(Account).to receive(:find_in_batches).and_yield([account])
end end
@ -90,11 +90,11 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
# Create multiple accounts # Create multiple accounts
5.times do |_i| 5.times do |_i|
acc = create(:account) acc = create(:account)
acc.enable_features('assignment_v2')
acc.save!
inb = create(:inbox, account: acc, enable_auto_assignment: true) inb = create(:inbox, account: acc, enable_auto_assignment: true)
policy = create(:assignment_policy, account: acc) policy = create(:assignment_policy, account: acc)
create(:inbox_assignment_policy, inbox: inb, assignment_policy: policy) 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) inbox_relation = instance_double(ActiveRecord::Relation)
allow(acc).to receive(:inboxes).and_return(inbox_relation) allow(acc).to receive(:inboxes).and_return(inbox_relation)

View File

@ -10,8 +10,9 @@ RSpec.describe AutoAssignment::AssignmentService do
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) } let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
before do before do
# Enable assignment_v2 feature for the account # Enable assignment_v2 feature for the account (basic assignment features)
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true) account.enable_features('assignment_v2')
account.save!
# Link inbox to assignment policy # Link inbox to assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
create(:inbox_member, inbox: inbox, user: agent) create(:inbox_member, inbox: inbox, user: agent)

View File

@ -59,8 +59,9 @@ RSpec.describe AutoAssignment::RateLimiter do
allow(inbox).to receive(:assignment_policy).and_return(nil) allow(inbox).to receive(:assignment_policy).and_return(nil)
end end
it 'does not track the assignment' do it 'still tracks the assignment with default window' do
expect(Redis::Alfred).not_to receive(:set) 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) rate_limiter.track_assignment(conversation)
end end
end end