feat: Agent assignment policy Create/Edit pages (#12400)

This commit is contained in:
Sivin Varghese
2025-09-10 20:02:11 +05:30
committed by GitHub
parent aba4e8bc53
commit 257df30589
26 changed files with 1765 additions and 8 deletions

View File

@@ -3,6 +3,8 @@ import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
import AssignmentPolicyIndex from './Index.vue';
import AgentAssignmentIndex from './pages/AgentAssignmentIndexPage.vue';
import AgentAssignmentCreate from './pages/AgentAssignmentCreatePage.vue';
import AgentAssignmentEdit from './pages/AgentAssignmentEditPage.vue';
export default {
routes: [
@@ -34,6 +36,24 @@ export default {
permissions: ['administrator'],
},
},
{
path: 'assignment/create',
name: 'agent_assignment_policy_create',
component: AgentAssignmentCreate,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
{
path: 'assignment/edit/:id',
name: 'agent_assignment_policy_edit',
component: AgentAssignmentEdit,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
],
},
],

View File

@@ -0,0 +1,17 @@
// Assignment order types
export const ROUND_ROBIN = 'round_robin';
export const BALANCED = 'balanced';
// Assignment priority types
export const EARLIEST_CREATED = 'earliest_created';
export const LONGEST_WAITING = 'longest_waiting';
// Default values for fair distribution
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
// Options groupings
export const OPTIONS = {
ORDER: [ROUND_ROBIN, BALANCED],
PRIORITY: [EARLIEST_CREATED, LONGEST_WAITING],
};

View File

@@ -0,0 +1,74 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { 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 router = useRouter();
const store = useStore();
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 handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
const handleSubmit = async formState => {
try {
const policy = await store.dispatch('assignmentPolicies/create', formState);
useAlert(
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.API.SUCCESS_MESSAGE')
);
formRef.value?.resetForm();
router.push({
name: 'agent_assignment_policy_edit',
params: {
id: policy.id,
},
});
} catch (error) {
useAlert(
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.API.ERROR_MESSAGE')
);
}
};
</script>
<template>
<SettingsLayout class="xl:px-44">
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
<template #body>
<AssignmentPolicyForm
ref="formRef"
mode="CREATE"
:is-loading="uiFlags.isCreating"
@submit="handleSubmit"
/>
</template>
</SettingsLayout>
</template>

View File

@@ -0,0 +1,197 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import {
ROUND_ROBIN,
EARLIEST_CREATED,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
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';
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const inboxes = useMapGetter('inboxes/getAllInboxes');
const inboxUiFlags = useMapGetter('assignmentPolicies/getInboxUiFlags');
const selectedPolicyById = useMapGetter(
'assignmentPolicies/getAssignmentPolicyById'
);
const routeId = computed(() => route.params.id);
const selectedPolicy = computed(() => selectedPolicyById.value(routeId.value));
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`) },
]);
const buildInboxList = allInboxes =>
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || [];
const policyInboxes = computed(() =>
buildInboxList(selectedPolicy.value?.inboxes)
);
const inboxList = computed(() =>
buildInboxList(
inboxes.value?.slice().sort((a, b) => a.name.localeCompare(b.name))
)
);
const formData = computed(() => ({
name: selectedPolicy.value?.name || '',
description: selectedPolicy.value?.description || '',
enabled: selectedPolicy.value?.enabled || false,
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
conversationPriority:
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 10,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 60,
}));
const handleDeleteInbox = inboxId =>
store.dispatch('assignmentPolicies/removeInboxPolicy', {
policyId: selectedPolicy.value?.id,
inboxId,
});
const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName });
const setInboxPolicy = async (inboxId, policyId) => {
try {
await store.dispatch('assignmentPolicies/setInboxPolicy', {
inboxId,
policyId,
});
useAlert(t(`${BASE_KEY}.FORM.INBOXES.API.SUCCESS_MESSAGE`));
await store.dispatch(
'assignmentPolicies/getInboxes',
Number(routeId.value)
);
return true;
} catch (error) {
useAlert(t(`${BASE_KEY}.FORM.INBOXES.API.ERROR_MESSAGE`));
return false;
}
};
const handleAddInbox = async inbox => {
try {
const policy = await store.dispatch('assignmentPolicies/getInboxPolicy', {
inboxId: inbox?.id,
});
if (policy?.id !== selectedPolicy.value?.id) {
inboxLinkedPolicy.value = {
...policy,
assignedInboxCount: policy.assignedInboxCount - 1,
};
confirmInboxDialogRef.value.openDialog(inbox);
return;
}
} catch (error) {
// If getInboxPolicy fails, continue to setInboxPolicy
}
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id);
};
const handleConfirmAddInbox = async inboxId => {
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
if (success) {
// Update the policy to reflect the assigned inbox count change
await store.dispatch('assignmentPolicies/updateInboxPolicy', {
policy: inboxLinkedPolicy.value,
});
// Fetch the updated inboxes for the policy after update, to reflect real-time changes
store.dispatch(
'assignmentPolicies/getInboxes',
inboxLinkedPolicy.value?.id
);
inboxLinkedPolicy.value = null;
confirmInboxDialogRef.value.closeDialog();
}
};
const handleSubmit = async formState => {
try {
await store.dispatch('assignmentPolicies/update', {
id: selectedPolicy.value?.id,
...formState,
});
useAlert(t(`${BASE_KEY}.EDIT.API.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.API.ERROR_MESSAGE`));
}
};
const fetchPolicyData = async () => {
if (!routeId.value) return;
// Fetch policy if not available
if (!selectedPolicy.value?.id)
await store.dispatch('assignmentPolicies/show', routeId.value);
await store.dispatch('assignmentPolicies/getInboxes', Number(routeId.value));
};
watch(routeId, fetchPolicyData, { immediate: true });
</script>
<template>
<SettingsLayout :is-loading="uiFlags.isFetchingItem" class="xl:px-44">
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
<template #body>
<AssignmentPolicyForm
:key="routeId"
mode="EDIT"
:initial-data="formData"
:policy-inboxes="policyInboxes"
:inbox-list="inboxList"
show-inbox-section
:is-loading="uiFlags.isUpdating"
:is-inbox-loading="inboxUiFlags.isFetching"
@submit="handleSubmit"
@add-inbox="handleAddInbox"
@delete-inbox="handleDeleteInbox"
/>
</template>
<ConfirmInboxDialog
ref="confirmInboxDialogRef"
@add="handleConfirmAddInbox"
/>
</SettingsLayout>
</template>

View File

@@ -44,7 +44,16 @@ const handleBreadcrumbClick = item => {
const onClickCreatePolicy = () => {
router.push({
name: 'assignment_policy_create',
name: 'agent_assignment_policy_create',
});
};
const onClickEditPolicy = id => {
router.push({
name: 'agent_assignment_policy_edit',
params: {
id,
},
});
};
@@ -106,6 +115,7 @@ onMounted(() => {
v-bind="policy"
:is-fetching-inboxes="inboxUiFlags.isFetching"
@fetch-inboxes="handleFetchInboxes"
@edit="onClickEditPolicy"
@delete="handleDelete"
/>
</div>

View File

@@ -0,0 +1,254 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useConfig } from 'dashboard/composables/useConfig';
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';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
OPTIONS,
ROUND_ROBIN,
EARLIEST_CREATED,
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
const props = defineProps({
initialData: {
type: Object,
default: () => ({
name: '',
description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
}),
},
mode: {
type: String,
required: true,
validator: value => ['CREATE', 'EDIT'].includes(value),
},
policyInboxes: {
type: Array,
default: () => [],
},
inboxList: {
type: Array,
default: () => [],
},
showInboxSection: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
isInboxLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'submit',
'addInbox',
'deleteInbox',
'validationChange',
]);
const { t } = useI18n();
const { isEnterprise } = useConfig();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const state = reactive({
name: '',
description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
});
const validationState = ref({
isValid: false,
});
const createOption = (type, key, stateKey) => ({
key,
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
isActive: state[stateKey] === key,
});
const assignmentOrderOptions = computed(() => {
const options = OPTIONS.ORDER.filter(
key => isEnterprise || key !== 'balanced'
);
return options.map(key =>
createOption('ASSIGNMENT_ORDER', key, 'assignmentOrder')
);
});
const assignmentPriorityOptions = computed(() =>
OPTIONS.PRIORITY.map(key =>
createOption('ASSIGNMENT_PRIORITY', key, 'conversationPriority')
)
);
const radioSections = computed(() => [
{
key: 'assignmentOrder',
label: t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.LABEL`),
options: assignmentOrderOptions.value,
},
{
key: 'conversationPriority',
label: t(`${BASE_KEY}.FORM.ASSIGNMENT_PRIORITY.LABEL`),
options: assignmentPriorityOptions.value,
},
]);
const buttonLabel = computed(() =>
t(`${BASE_KEY}.${props.mode.toUpperCase()}.${props.mode}_BUTTON`)
);
const handleValidationChange = validation => {
validationState.value = validation;
emit('validationChange', validation);
};
const resetForm = () => {
Object.assign(state, {
name: '',
description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
});
};
const handleSubmit = () => {
emit('submit', { ...state });
};
watch(
() => props.initialData,
newData => {
Object.assign(state, newData);
},
{ immediate: true, deep: true }
);
defineExpose({
resetForm,
});
</script>
<template>
<form @submit.prevent="handleSubmit">
<div class="flex flex-col gap-4 divide-y divide-n-weak mb-4">
<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"
/>
<div class="flex flex-col items-center">
<div
v-for="section in radioSections"
:key="section.key"
class="py-4 flex flex-col items-start gap-3 w-full"
>
<WithLabel
:label="section.label"
name="assignmentPolicy"
class="w-full flex items-start flex-col gap-3"
>
<div class="grid grid-cols-1 xs:grid-cols-2 gap-4 w-full">
<RadioCard
v-for="option in section.options"
:id="option.key"
:key="option.key"
:label="option.label"
:description="option.description"
:is-active="option.isActive"
@select="state[section.key] = $event"
/>
</div>
</WithLabel>
</div>
</div>
<div class="pt-4 pb-2 flex-col flex gap-4">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.FAIR_DISTRIBUTION.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.FAIR_DISTRIBUTION.DESCRIPTION`) }}
</p>
</div>
<FairDistribution
v-model:fair-distribution-limit="state.fairDistributionLimit"
v-model:fair-distribution-window="state.fairDistributionWindow"
v-model:window-unit="state.windowUnit"
/>
</div>
<div v-if="showInboxSection" class="py-4 flex-col flex gap-4">
<div class="flex items-end gap-4 w-full justify-between">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.INBOXES.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.INBOXES.DESCRIPTION`) }}
</p>
</div>
<AddDataDropdown
:label="t(`${BASE_KEY}.FORM.INBOXES.ADD_BUTTON`)"
:search-placeholder="
t(`${BASE_KEY}.FORM.INBOXES.DROPDOWN.SEARCH_PLACEHOLDER`)
"
:items="inboxList"
@add="$emit('addInbox', $event)"
/>
</div>
<DataTable
:items="policyInboxes"
:is-fetching="isInboxLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
@delete="$emit('deleteInbox', $event)"
/>
</div>
</div>
<Button
type="submit"
:label="buttonLabel"
:disabled="!validationState.isValid || isLoading"
:is-loading="isLoading"
/>
</form>
</template>

View File

@@ -0,0 +1,59 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['add']);
const { t } = useI18n();
const dialogRef = ref(null);
const currentInbox = ref(null);
const openDialog = inbox => {
currentInbox.value = inbox;
dialogRef.value.open();
};
const closeDialog = () => {
dialogRef.value.close();
};
const handleDialogConfirm = () => {
emit('add', currentInbox.value.id);
};
defineExpose({ openDialog, closeDialog });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.TITLE'
)
"
:description="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.DESCRIPTION',
{
inboxName: currentInbox?.name,
}
)
"
:confirm-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.CONFIRM_BUTTON_LABEL'
)
"
:cancel-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.CANCEL_BUTTON_LABEL'
)
"
@confirm="handleDialogConfirm"
/>
</template>