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:
Sivin Varghese
2025-09-12 18:42:55 +05:30
committed by GitHub
parent 699731d351
commit ca579bd62a
21 changed files with 1965 additions and 33 deletions

View File

@@ -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'],
},
},
],
},
],

View File

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

View File

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

View File

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