feat: Agent capacity policy Create/Edit pages (#12424)
# Pull Request Template ## Description Fixes https://linear.app/chatwoot/issue/CW-5573/feat-createedit-agent-capacity-policy-page ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/8de9e3c5d8824cd998d242636540dd18?sid=1314536f-c8d6-41fd-8139-cae9bf94f942 ### Screenshots **Light mode** <img width="1666" height="1225" alt="image" src="https://github.com/user-attachments/assets/7e6d83a4-ce02-47a7-91f6-87745f8f5549" /> <img width="1666" height="1225" alt="image" src="https://github.com/user-attachments/assets/7dd1f840-2e25-4365-aa1d-ed9dac13385a" /> **Dark mode** <img width="1666" height="1225" alt="image" src="https://github.com/user-attachments/assets/0c787095-7146-4fb3-a61a-e2232973bcba" /> <img width="1666" height="1225" alt="image" src="https://github.com/user-attachments/assets/481c21fd-03b5-4c1f-b59e-7f8c8017f9ce" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import AgentAssignmentIndex from './pages/AgentAssignmentIndexPage.vue';
|
||||
import AgentAssignmentCreate from './pages/AgentAssignmentCreatePage.vue';
|
||||
import AgentAssignmentEdit from './pages/AgentAssignmentEditPage.vue';
|
||||
import AgentCapacityIndex from './pages/AgentCapacityIndexPage.vue';
|
||||
import AgentCapacityCreate from './pages/AgentCapacityCreatePage.vue';
|
||||
import AgentCapacityEdit from './pages/AgentCapacityEditPage.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -64,6 +66,24 @@ export default {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'capacity/create',
|
||||
name: 'agent_capacity_policy_create',
|
||||
component: AgentCapacityCreate,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'capacity/edit/:id',
|
||||
name: 'agent_capacity_policy_edit',
|
||||
component: AgentCapacityEdit,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<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 AgentCapacityPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const formRef = ref(null);
|
||||
const uiFlags = useMapGetter('agentCapacityPolicies/getUIFlags');
|
||||
const labelsList = useMapGetter('labels/getLabels');
|
||||
|
||||
const allLabels = computed(() =>
|
||||
labelsList.value?.map(({ title, color, id }) => ({
|
||||
id,
|
||||
name: title,
|
||||
color,
|
||||
}))
|
||||
);
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.HEADER.TITLE'),
|
||||
routeName: 'agent_capacity_policy_index',
|
||||
},
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.CREATE.HEADER.TITLE'),
|
||||
},
|
||||
]);
|
||||
|
||||
const handleBreadcrumbClick = item => {
|
||||
router.push({
|
||||
name: item.routeName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async formState => {
|
||||
try {
|
||||
const policy = await store.dispatch(
|
||||
'agentCapacityPolicies/create',
|
||||
formState
|
||||
);
|
||||
useAlert(
|
||||
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.CREATE.API.SUCCESS_MESSAGE')
|
||||
);
|
||||
formRef.value?.resetForm();
|
||||
|
||||
router.push({
|
||||
name: 'agent_capacity_policy_edit',
|
||||
params: {
|
||||
id: policy.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_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>
|
||||
<AgentCapacityPolicyForm
|
||||
ref="formRef"
|
||||
mode="CREATE"
|
||||
:is-loading="uiFlags.isCreating"
|
||||
:label-list="allLabels"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup>
|
||||
import { computed, watch, onMounted } 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 camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
|
||||
import AgentCapacityPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue';
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const uiFlags = useMapGetter('agentCapacityPolicies/getUIFlags');
|
||||
const usersUiFlags = useMapGetter('agentCapacityPolicies/getUsersUIFlags');
|
||||
const selectedPolicyById = useMapGetter(
|
||||
'agentCapacityPolicies/getAgentCapacityPolicyById'
|
||||
);
|
||||
const agentsList = useMapGetter('agents/getAgents');
|
||||
const labelsList = useMapGetter('labels/getLabels');
|
||||
const inboxes = useMapGetter('inboxes/getAllInboxes');
|
||||
const inboxesUiFlags = useMapGetter('inboxes/getUIFlags');
|
||||
|
||||
const routeId = computed(() => route.params.id);
|
||||
const selectedPolicy = computed(() => selectedPolicyById.value(routeId.value));
|
||||
const selectedPolicyId = computed(() => selectedPolicy.value?.id);
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{
|
||||
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
|
||||
routeName: 'agent_capacity_policy_index',
|
||||
},
|
||||
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
||||
]);
|
||||
|
||||
const buildList = items =>
|
||||
items?.map(({ name, title, id, email, avatarUrl, thumbnail, color }) => ({
|
||||
name: name || title,
|
||||
id,
|
||||
email,
|
||||
avatarUrl: avatarUrl || thumbnail,
|
||||
color,
|
||||
})) || [];
|
||||
|
||||
const policyUsers = computed(() => buildList(selectedPolicy.value?.users));
|
||||
|
||||
const allAgents = computed(() =>
|
||||
buildList(camelcaseKeys(agentsList.value)).filter(
|
||||
agent => !policyUsers.value?.some(user => user.id === agent.id)
|
||||
)
|
||||
);
|
||||
|
||||
const allLabels = computed(() => buildList(labelsList.value));
|
||||
|
||||
const allInboxes = computed(() => buildList(inboxes.value));
|
||||
|
||||
const formData = computed(() => ({
|
||||
name: selectedPolicy.value?.name || '',
|
||||
description: selectedPolicy.value?.description || '',
|
||||
exclusionRules: {
|
||||
excludedLabels: [
|
||||
...(selectedPolicy.value?.exclusionRules?.excludedLabels || []),
|
||||
],
|
||||
excludeOlderThanHours:
|
||||
selectedPolicy.value?.exclusionRules?.excludeOlderThanHours || 10,
|
||||
},
|
||||
inboxCapacityLimits:
|
||||
selectedPolicy.value?.inboxCapacityLimits?.map(limit => ({
|
||||
...limit,
|
||||
})) || [],
|
||||
}));
|
||||
|
||||
const handleBreadcrumbClick = ({ routeName }) =>
|
||||
router.push({ name: routeName });
|
||||
|
||||
const handleDeleteUser = agentId => {
|
||||
store.dispatch('agentCapacityPolicies/removeUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userId: agentId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddUser = agent => {
|
||||
store.dispatch('agentCapacityPolicies/addUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userData: { id: agent.id, capacity: 20 },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteInboxLimit = limitId => {
|
||||
store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddInboxLimit = limit => {
|
||||
store.dispatch('agentCapacityPolicies/createInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitData: {
|
||||
inboxId: limit.inboxId,
|
||||
conversationLimit: limit.conversationLimit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleLimitChange = limit => {
|
||||
store.dispatch('agentCapacityPolicies/updateInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId: limit.id,
|
||||
limitData: { conversationLimit: limit.conversationLimit },
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async formState => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/update', {
|
||||
id: selectedPolicyId.value,
|
||||
...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 (!selectedPolicyId.value)
|
||||
await store.dispatch('agentCapacityPolicies/show', routeId.value);
|
||||
|
||||
await store.dispatch('agentCapacityPolicies/getUsers', Number(routeId.value));
|
||||
};
|
||||
|
||||
watch(routeId, fetchPolicyData, { immediate: true });
|
||||
onMounted(() => store.dispatch('agents/get'));
|
||||
</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>
|
||||
<AgentCapacityPolicyForm
|
||||
:key="routeId"
|
||||
mode="EDIT"
|
||||
:initial-data="formData"
|
||||
:policy-users="policyUsers"
|
||||
:agent-list="allAgents"
|
||||
:label-list="allLabels"
|
||||
:inbox-list="allInboxes"
|
||||
show-user-section
|
||||
show-inbox-limit-section
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
:is-users-loading="usersUiFlags.isFetching"
|
||||
:is-inboxes-loading="inboxesUiFlags.isFetching"
|
||||
@submit="handleSubmit"
|
||||
@add-user="handleAddUser"
|
||||
@delete-user="handleDeleteUser"
|
||||
@add-inbox-limit="handleAddInboxLimit"
|
||||
@update-inbox-limit="handleLimitChange"
|
||||
@delete-inbox-limit="handleDeleteInboxLimit"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
|
||||
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ExclusionRules from 'dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue';
|
||||
import InboxCapacityLimits from 'dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue';
|
||||
|
||||
const props = defineProps({
|
||||
initialData: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
exclusionRules: {
|
||||
excludedLabels: [],
|
||||
excludeOlderThanHours: 10,
|
||||
},
|
||||
inboxCapacityLimits: [],
|
||||
}),
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['CREATE', 'EDIT'].includes(value),
|
||||
},
|
||||
policyUsers: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
agentList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
labelList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
inboxList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showUserSection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showInboxLimitSection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isUsersLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isInboxesLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'submit',
|
||||
'addUser',
|
||||
'deleteUser',
|
||||
'validationChange',
|
||||
'deleteInboxLimit',
|
||||
'addInboxLimit',
|
||||
'updateInboxLimit',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
|
||||
|
||||
const state = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
exclusionRules: {
|
||||
excludedLabels: [],
|
||||
excludeOlderThanHours: 10,
|
||||
},
|
||||
inboxCapacityLimits: [],
|
||||
});
|
||||
|
||||
const validationState = ref({
|
||||
isValid: false,
|
||||
});
|
||||
|
||||
const buttonLabel = computed(() =>
|
||||
t(`${BASE_KEY}.${props.mode.toUpperCase()}.${props.mode}_BUTTON`)
|
||||
);
|
||||
|
||||
const handleValidationChange = validation => {
|
||||
validationState.value = validation;
|
||||
emit('validationChange', validation);
|
||||
};
|
||||
|
||||
const handleDeleteInboxLimit = id => {
|
||||
emit('deleteInboxLimit', id);
|
||||
};
|
||||
|
||||
const handleAddInboxLimit = limit => {
|
||||
emit('addInboxLimit', limit);
|
||||
};
|
||||
|
||||
const handleLimitChange = limit => {
|
||||
emit('updateInboxLimit', limit);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(state, {
|
||||
name: '',
|
||||
description: '',
|
||||
exclusionRules: {
|
||||
excludedLabels: [],
|
||||
excludeOlderThanHours: 10,
|
||||
},
|
||||
inboxCapacityLimits: [],
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
<BaseInfo
|
||||
v-model:policy-name="state.name"
|
||||
v-model:description="state.description"
|
||||
: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`)"
|
||||
@validation-change="handleValidationChange"
|
||||
/>
|
||||
<ExclusionRules
|
||||
v-model:excluded-labels="state.exclusionRules.excludedLabels"
|
||||
v-model:exclude-older-than-minutes="
|
||||
state.exclusionRules.excludeOlderThanHours
|
||||
"
|
||||
:tags-list="labelList"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
:label="buttonLabel"
|
||||
:disabled="!validationState.isValid || isLoading"
|
||||
:is-loading="isLoading"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="showInboxLimitSection || showUserSection"
|
||||
class="flex flex-col gap-4 divide-y divide-n-weak border-t border-n-weak mt-6"
|
||||
>
|
||||
<InboxCapacityLimits
|
||||
v-if="showInboxLimitSection"
|
||||
v-model:inbox-capacity-limits="state.inboxCapacityLimits"
|
||||
:inbox-list="inboxList"
|
||||
:is-fetching="isInboxesLoading"
|
||||
@delete="handleDeleteInboxLimit"
|
||||
@add="handleAddInboxLimit"
|
||||
@update="handleLimitChange"
|
||||
/>
|
||||
<div v-if="showUserSection" 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.USERS.LABEL`) }}
|
||||
</label>
|
||||
<p class="mb-0 text-n-slate-11 text-sm">
|
||||
{{ t(`${BASE_KEY}.FORM.USERS.DESCRIPTION`) }}
|
||||
</p>
|
||||
</div>
|
||||
<AddDataDropdown
|
||||
:label="t(`${BASE_KEY}.FORM.USERS.ADD_BUTTON`)"
|
||||
:search-placeholder="
|
||||
t(`${BASE_KEY}.FORM.USERS.DROPDOWN.SEARCH_PLACEHOLDER`)
|
||||
"
|
||||
:items="agentList"
|
||||
@add="$emit('addUser', $event)"
|
||||
/>
|
||||
</div>
|
||||
<DataTable
|
||||
:items="policyUsers"
|
||||
:is-fetching="isUsersLoading"
|
||||
:empty-state-message="t(`${BASE_KEY}.FORM.USERS.EMPTY_STATE`)"
|
||||
@delete="$emit('deleteUser', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
Reference in New Issue
Block a user