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

@@ -4,6 +4,7 @@ import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { picoSearch } from '@scmmishra/pico-search';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -36,8 +37,8 @@ const filteredItems = computed(() => {
return picoSearch(props.items, query, ['name']);
});
const handleAdd = inbox => {
emit('add', inbox);
const handleAdd = item => {
emit('add', item);
togglePopover(false);
};
@@ -82,21 +83,35 @@ const handleClickOutside = () => {
<div
v-for="item in filteredItems"
:key="item.id"
class="flex items-start gap-3 min-w-0 w-full py-4 px-3 hover:bg-n-alpha-2 cursor-pointer"
class="flex gap-3 min-w-0 w-full py-4 px-3 hover:bg-n-alpha-2 cursor-pointer"
:class="{ 'items-center': item.color, 'items-start': !item.color }"
@click="handleAdd(item)"
>
<Icon
v-if="item.icon"
:icon="item.icon"
class="size-4 text-n-slate-12 flex-shrink-0 mt-0.5"
class="size-2 text-n-slate-12 flex-shrink-0 mt-0.5"
/>
<span
v-else-if="item.color"
:style="{ backgroundColor: item.color }"
class="size-3 rounded-sm"
/>
<Avatar
v-else
:title="item.name"
:src="item.avatarUrl"
:name="item.name"
:size="20"
rounded-full
/>
<div class="flex flex-col items-start gap-2 min-w-0">
<div class="flex items-center gap-1 min-w-0">
<span
:title="item.name"
:title="item.name || item.title"
class="text-sm text-n-slate-12 truncate min-w-0"
>
{{ item.name }}
{{ item.name || item.title }}
</span>
<span
v-if="item.id"

View File

@@ -65,6 +65,7 @@ watch(
() => {
emit('validationChange', {
isValid: isValid.value,
section: 'baseInfo',
});
},
{ immediate: true }
@@ -108,7 +109,7 @@ watch(
</div>
<!-- Status Field -->
<div class="flex items-center gap-6">
<div v-if="statusLabel" class="flex items-center gap-6">
<WithLabel
:label="statusLabel"
name="enabled"

View File

@@ -1,4 +1,5 @@
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
@@ -32,12 +33,14 @@ const handleDelete = itemId => {
>
<Spinner />
</div>
<span
<div
v-else-if="items.length === 0 && emptyStateMessage"
class="flex items-center justify-center pt-4 pb-8 w-full text-sm text-n-slate-11"
class="custom-dashed-border flex items-center justify-center py-6 w-full"
>
{{ emptyStateMessage }}
</span>
<span class="text-sm text-n-slate-11">
{{ emptyStateMessage }}
</span>
</div>
<div v-else class="flex flex-col divide-y divide-n-weak">
<div
v-for="item in items"
@@ -50,7 +53,14 @@ const handleDelete = itemId => {
:icon="item.icon"
class="size-4 text-n-slate-12 flex-shrink-0"
/>
<Avatar
v-else
:title="item.name"
:src="item.avatarUrl"
:name="item.name"
:size="20"
rounded-full
/>
<span class="text-sm text-n-slate-12 truncate min-w-0">
{{ item.name }}
</span>

View File

@@ -0,0 +1,149 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const props = defineProps({
tagsList: {
type: Array,
default: () => [],
},
});
const excludedLabels = defineModel('excludedLabels', {
type: Array,
default: () => [],
});
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
type: Number,
default: 10,
});
// Duration limits: 10 minutes to 999 days (in minutes)
const MIN_DURATION_MINUTES = 10;
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
const { t } = useI18n();
const hoveredLabel = ref(null);
const windowUnit = ref(DURATION_UNITS.MINUTES);
const addedTags = computed(() =>
props.tagsList
.filter(label => excludedLabels.value.includes(label.name))
.map(label => ({ id: label.id, title: label.name, ...label }))
);
const filteredTags = computed(() =>
props.tagsList.filter(
label => !addedTags.value.some(tag => tag.id === label.id)
)
);
const detectUnit = minutes => {
const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES;
if (m % (24 * 60) === 0) return DURATION_UNITS.DAYS;
if (m % 60 === 0) return DURATION_UNITS.HOURS;
return DURATION_UNITS.MINUTES;
};
const onClickAddTag = tag => {
excludedLabels.value = [...excludedLabels.value, tag.name];
};
const onClickRemoveTag = tag => {
excludedLabels.value = excludedLabels.value.filter(
name => name !== tag.title
);
};
onMounted(() => {
windowUnit.value = detectUnit(excludeOlderThanMinutes.value);
});
</script>
<template>
<div class="py-4 flex-col flex gap-6">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.LABEL'
)
}}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DESCRIPTION'
)
}}
</p>
</div>
<div class="flex flex-col items-start gap-4">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.LABEL'
)
}}
</label>
<div
class="flex items-start gap-2 flex-wrap"
@mouseleave="hoveredLabel = null"
>
<LabelItem
v-for="tag in addedTags"
:key="tag.id"
:label="tag"
:is-hovered="hoveredLabel === tag.id"
class="h-8"
@remove="onClickRemoveTag"
@hover="hoveredLabel = tag.id"
/>
<AddDataDropdown
:label="
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.ADD_TAG'
)
"
:search-placeholder="
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.DROPDOWN.SEARCH_PLACEHOLDER'
)
"
:items="filteredTags"
class="[&>button]:!text-n-blue-text"
@add="onClickAddTag"
/>
</div>
</div>
<div class="flex flex-col items-start gap-4">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DURATION.LABEL'
)
}}
</label>
<div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model:unit="windowUnit"
v-model:model-value="excludeOlderThanMinutes"
:min="MIN_DURATION_MINUTES"
:max="MAX_DURATION_MINUTES"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,163 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
const props = defineProps({
inboxList: {
type: Array,
default: () => [],
},
isFetching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['delete', 'add', 'update']);
const inboxCapacityLimits = defineModel('inboxCapacityLimits', {
type: Array,
default: () => [],
});
const { t } = useI18n();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const DEFAULT_CONVERSATION_LIMIT = 10;
const MIN_CONVERSATION_LIMIT = 1;
const MAX_CONVERSATION_LIMIT = 100000;
const selectedInboxIds = computed(
() => new Set(inboxCapacityLimits.value.map(limit => limit.inboxId))
);
const availableInboxes = computed(() =>
props.inboxList.filter(
inbox => inbox && !selectedInboxIds.value.has(inbox.id)
)
);
const isLimitValid = limit => {
return (
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
);
};
const inboxMap = computed(
() => new Map(props.inboxList.map(inbox => [inbox.id, inbox]))
);
const handleAddInbox = inbox => {
emit('add', {
inboxId: inbox.id,
conversationLimit: DEFAULT_CONVERSATION_LIMIT,
});
};
const handleRemoveLimit = limitId => {
emit('delete', limitId);
};
const handleLimitChange = limit => {
if (isLimitValid(limit)) {
emit('update', limit);
}
};
const getInboxName = inboxId => {
return inboxMap.value.get(inboxId)?.name || '';
};
</script>
<template>
<div class="py-4 flex-col flex gap-3">
<div class="flex items-center w-full gap-8 justify-between pt-1 pb-3">
<label class="text-sm font-medium text-n-slate-12">
{{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.LABEL`) }}
</label>
<AddDataDropdown
:label="t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.ADD_BUTTON`)"
:search-placeholder="
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SELECT_INBOX`)
"
:items="availableInboxes"
@add="handleAddInbox"
/>
</div>
<div
v-if="isFetching"
class="flex items-center justify-center py-3 w-full text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="!inboxCapacityLimits.length"
class="custom-dashed-border flex items-center justify-center py-6 w-full"
>
<span class="text-sm text-n-slate-11">
{{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.EMPTY_STATE`) }}
</span>
</div>
<div v-else class="flex-col flex gap-3">
<div
v-for="(limit, index) in inboxCapacityLimits"
:key="limit.id || `temp-${index}`"
class="flex items-center gap-3"
>
<div
class="flex items-start rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full"
>
{{ getInboxName(limit.inboxId) }}
</div>
<div
class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-shrink-0 flex items-center"
:class="[
!isLimitValid(limit) ? 'outline-n-ruby-8' : 'outline-n-weak',
]"
>
<label class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2">
{{
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`)
}}
</label>
<div class="h-5 w-px bg-n-weak" />
<input
v-model.number="limit.conversationLimit"
type="number"
:min="MIN_CONVERSATION_LIMIT"
:max="MAX_CONVERSATION_LIMIT"
class="reset-base bg-transparent focus:outline-none max-w-20 text-sm"
:class="[
!isLimitValid(limit)
? 'placeholder:text-n-ruby-9 !text-n-ruby-9'
: 'placeholder:text-n-slate-10 text-n-slate-12',
]"
:placeholder="
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SET_LIMIT`)
"
@blur="handleLimitChange(limit)"
/>
</div>
<Button
type="button"
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click="handleRemoveLimit(limit.id)"
/>
</div>
</div>
</div>
</template>

View File

@@ -34,6 +34,29 @@ const mockInboxes = [
},
];
const mockTags = [
{
id: 1,
name: 'urgent',
color: '#ff4757',
},
{
id: 2,
name: 'bug',
color: '#ff6b6b',
},
{
id: 3,
name: 'feature-request',
color: '#4834d4',
},
{
id: 4,
name: 'documentation',
color: '#26de81',
},
];
const handleAdd = item => {
console.log('Add item:', item);
};
@@ -42,9 +65,9 @@ const handleAdd = item => {
<template>
<Story
title="Components/AgentManagementPolicy/AddDataDropdown"
:layout="{ type: 'grid', width: '400px' }"
:layout="{ type: 'grid', width: '500px' }"
>
<Variant title="Basic Usage">
<Variant title="Basic Usage - Inboxes">
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
<AddDataDropdown
label="Add Inbox"
@@ -54,5 +77,16 @@ const handleAdd = item => {
/>
</div>
</Variant>
<Variant title="Basic Usage - Tags">
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
<AddDataDropdown
label="Add Tag"
search-placeholder="Search tags..."
:items="mockTags"
@add="handleAdd"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -22,6 +22,21 @@ const mockItems = [
},
];
const mockAgentList = [
{
id: 1,
name: 'John Doe',
email: 'john.doe@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=1',
},
{
id: 2,
name: 'Jane Smith',
email: 'jane.smith@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=2',
},
];
const handleDelete = itemId => {
console.log('Delete item:', itemId);
};
@@ -30,7 +45,7 @@ const handleDelete = itemId => {
<template>
<Story
title="Components/AgentManagementPolicy/DataTable"
:layout="{ type: 'grid', width: '600px' }"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With Data">
<div class="p-8 bg-n-background">
@@ -42,6 +57,16 @@ const handleDelete = itemId => {
</div>
</Variant>
<Variant title="With Agents">
<div class="p-8 bg-n-background">
<DataTable
:items="mockAgentList"
:is-fetching="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-8 bg-n-background">
<DataTable :items="[]" is-fetching @delete="handleDelete" />

View File

@@ -0,0 +1,67 @@
<script setup>
import ExclusionRules from '../ExclusionRules.vue';
import { ref } from 'vue';
const mockTagsList = [
{
id: 1,
name: 'urgent',
color: '#ff4757',
},
{
id: 2,
name: 'bug',
color: '#ff6b6b',
},
{
id: 3,
name: 'feature-request',
color: '#4834d4',
},
{
id: 4,
name: 'documentation',
color: '#26de81',
},
{
id: 5,
name: 'enhancement',
color: '#2ed573',
},
{
id: 6,
name: 'question',
color: '#ffa502',
},
{
id: 7,
name: 'duplicate',
color: '#747d8c',
},
{
id: 8,
name: 'wontfix',
color: '#57606f',
},
];
const excludedLabelsBasic = ref([]);
const excludeOlderThanHoursBasic = ref(10);
</script>
<template>
<Story
title="Components/AgentManagementPolicy/ExclusionRules"
:layout="{ type: 'grid', width: '1200px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background h-[600px]">
<ExclusionRules
v-model:excluded-labels="excludedLabelsBasic"
v-model:exclude-older-than-minutes="excludeOlderThanHoursBasic"
:tags-list="mockTagsList"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,108 @@
<script setup>
import InboxCapacityLimits from '../InboxCapacityLimits.vue';
import { ref } from 'vue';
const mockInboxList = [
{
value: 1,
label: 'Website Support',
icon: 'i-lucide-globe',
},
{
value: 2,
label: 'Email Support',
icon: 'i-lucide-mail',
},
{
value: 3,
label: 'WhatsApp Business',
icon: 'i-lucide-message-circle',
},
{
value: 4,
label: 'Facebook Messenger',
icon: 'i-lucide-facebook',
},
{
value: 5,
label: 'Twitter DM',
icon: 'i-lucide-twitter',
},
{
value: 6,
label: 'Telegram',
icon: 'i-lucide-send',
},
];
const inboxCapacityLimitsEmpty = ref([]);
const inboxCapacityLimitsNew = ref([
{ id: 1, inboxId: 1, conversationLimit: 5 },
{ inboxId: null, conversationLimit: null },
]);
const handleDelete = id => {
console.log('Delete capacity limit:', id);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/InboxCapacityLimits"
:layout="{ type: 'grid', width: '900px' }"
>
<Variant title="Empty State">
<div class="p-8 bg-n-background">
<InboxCapacityLimits
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
:inbox-list="mockInboxList"
:is-fetching="false"
:is-updating="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-8 bg-n-background">
<InboxCapacityLimits
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
:inbox-list="mockInboxList"
is-fetching
:is-updating="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="With New Row and existing data">
<div class="p-8 bg-n-background">
<InboxCapacityLimits
v-model:inbox-capacity-limits="inboxCapacityLimitsNew"
:inbox-list="mockInboxList"
:is-fetching="false"
:is-updating="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="Interactive Demo">
<div class="p-8 bg-n-background">
<InboxCapacityLimits
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
:inbox-list="mockInboxList"
:is-fetching="false"
:is-updating="false"
@delete="handleDelete"
/>
<div class="mt-4 p-4 bg-n-alpha-2 rounded-lg">
<h4 class="text-sm font-medium mb-2">Current Limits:</h4>
<pre class="text-xs">{{
JSON.stringify(inboxCapacityLimitsEmpty, null, 2)
}}</pre>
</div>
</div>
</Variant>
</Story>
</template>