Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,116 @@
<script setup>
import AgentCapacityPolicyCard from './AgentCapacityPolicyCard.vue';
const mockUsers = [
{
id: 1,
name: 'John Smith',
email: 'john.smith@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=1',
},
{
id: 2,
name: 'Sarah Johnson',
email: 'sarah.johnson@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=2',
},
{
id: 3,
name: 'Mike Chen',
email: 'mike.chen@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=3',
},
{
id: 4,
name: 'Emily Davis',
email: 'emily.davis@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=4',
},
{
id: 5,
name: 'Alex Rodriguez',
email: 'alex.rodriguez@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=5',
},
];
const withCount = policy => ({
...policy,
assignedAgentCount: policy.users.length,
});
const policyA = withCount({
id: 1,
name: 'High Volume Support',
description:
'Capacity-based policy for handling high conversation volumes with experienced agents',
users: [mockUsers[0], mockUsers[1], mockUsers[2]],
isFetchingUsers: false,
});
const policyB = withCount({
id: 2,
name: 'Specialized Team',
description: 'Custom capacity limits for specialized support team members',
users: [mockUsers[3], mockUsers[4]],
isFetchingUsers: false,
});
const emptyPolicy = withCount({
id: 3,
name: 'New Policy',
description: 'Recently created policy with no assigned agents yet',
users: [],
isFetchingUsers: false,
});
const loadingPolicy = withCount({
id: 4,
name: 'Loading Policy',
description: 'Policy currently loading agent information',
users: [],
isFetchingUsers: true,
});
const onEdit = id => console.log('Edit policy:', id);
const onDelete = id => console.log('Delete policy:', id);
const onFetchUsers = id => console.log('Fetch users for policy:', id);
</script>
<template>
<Story
title="Components/AgentManagementPolicy/AgentCapacityPolicyCard"
:layout="{ type: 'grid', width: '1200px' }"
>
<Variant title="Multiple Cards (Various States)">
<div class="p-4 bg-n-background">
<div class="grid grid-cols-1 gap-4">
<AgentCapacityPolicyCard
v-bind="policyA"
@edit="onEdit"
@delete="onDelete"
@fetch-users="onFetchUsers"
/>
<AgentCapacityPolicyCard
v-bind="policyB"
@edit="onEdit"
@delete="onDelete"
@fetch-users="onFetchUsers"
/>
<AgentCapacityPolicyCard
v-bind="emptyPolicy"
@edit="onEdit"
@delete="onDelete"
@fetch-users="onFetchUsers"
/>
<AgentCapacityPolicyCard
v-bind="loadingPolicy"
@edit="onEdit"
@delete="onDelete"
@fetch-users="onFetchUsers"
/>
</div>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,86 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import CardPopover from '../components/CardPopover.vue';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
description: { type: String, default: '' },
assignedAgentCount: { type: Number, default: 0 },
users: { type: Array, default: () => [] },
isFetchingUsers: { type: Boolean, default: false },
});
const emit = defineEmits(['edit', 'delete', 'fetchUsers']);
const { t } = useI18n();
const users = computed(() => {
return props.users.map(user => {
return {
name: user.name,
key: user.id,
email: user.email,
avatarUrl: user.avatarUrl,
};
});
});
const handleEdit = () => {
emit('edit', props.id);
};
const handleDelete = () => {
emit('delete', props.id);
};
const handleFetchUsers = () => {
if (props.users?.length > 0) return;
emit('fetchUsers', props.id);
};
</script>
<template>
<CardLayout class="[&>div]:px-5">
<div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3">
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<CardPopover
:title="
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.CARD.POPOVER')
"
icon="i-lucide-users-round"
:count="assignedAgentCount"
:items="users"
:is-fetching="isFetchingUsers"
@fetch="handleFetchUsers"
/>
</div>
<div class="flex items-center gap-2">
<Button
:label="
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.CARD.EDIT')
"
sm
slate
link
class="px-2"
@click="handleEdit"
/>
<div class="w-px h-2.5 bg-n-slate-5" />
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
{{ description }}
</p>
</div>
</CardLayout>
</template>

View File

@@ -0,0 +1,63 @@
<script setup>
import AssignmentCard from './AssignmentCard.vue';
const agentAssignments = [
{
id: 1,
title: 'Assignment policy',
description: 'Manage how conversations get assigned in inboxes.',
features: [
{
icon: 'i-lucide-circle-fading-arrow-up',
label: 'Assign by conversations evenly or by available capacity',
},
{
icon: 'i-lucide-scale',
label: 'Add fair distribution rules to avoid overloading any agent',
},
{
icon: 'i-lucide-inbox',
label: 'Add inboxes to a policy - one policy per inbox',
},
],
},
{
id: 2,
title: 'Agent capacity policy',
description: 'Manage workload for agents.',
features: [
{
icon: 'i-lucide-glass-water',
label: 'Define maximum conversations per inbox',
},
{
icon: 'i-lucide-circle-minus',
label: 'Create exceptions based on labels and time',
},
{
icon: 'i-lucide-users-round',
label: 'Add agents to a policy - one policy per agent',
},
],
},
];
</script>
<template>
<Story
title="Components/AgentManagementPolicy/AssignmentCard"
:layout="{ type: 'grid', width: '1000px' }"
>
<Variant title="Assignment Card">
<div class="px-4 py-4 bg-n-background flex gap-6 justify-between">
<AssignmentCard
v-for="(item, index) in agentAssignments"
:key="index"
:title="item.title"
:description="item.description"
:features="item.features"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,49 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
defineProps({
title: { type: String, default: '' },
description: { type: String, default: '' },
features: { type: Array, default: () => [] },
});
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
</script>
<template>
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
<div class="flex flex-col items-start gap-2">
<div class="flex justify-between w-full items-center">
<h3 class="text-n-slate-12 text-heading-2">{{ title }}</h3>
<Button
xs
slate
ghost
icon="i-lucide-chevron-right"
@click.stop="handleClick"
/>
</div>
<p class="text-n-slate-11 text-body-para mb-0">{{ description }}</p>
</div>
<ul class="flex flex-col items-start gap-3 mt-3">
<li
v-for="feature in features"
:key="feature.id"
class="flex items-center gap-3 text-body-para"
>
<Icon
:icon="feature.icon"
class="text-n-slate-11 size-4 flex-shrink-0"
/>
{{ feature.label }}
</li>
</ul>
</CardLayout>
</template>

View File

@@ -0,0 +1,101 @@
<script setup>
import AssignmentPolicyCard from './AssignmentPolicyCard.vue';
const mockInboxes = [
{
id: 1,
name: 'Website Support',
channel_type: 'Channel::WebWidget',
inbox_type: 'Website',
},
{
id: 2,
name: 'Email Support',
channel_type: 'Channel::Email',
inbox_type: 'Email',
},
{
id: 3,
name: 'WhatsApp Business',
channel_type: 'Channel::Whatsapp',
inbox_type: 'WhatsApp',
},
{
id: 4,
name: 'Facebook Messenger',
channel_type: 'Channel::FacebookPage',
inbox_type: 'Messenger',
},
];
const withCount = policy => ({
...policy,
assignedInboxCount: policy.inboxes.length,
});
const policyA = withCount({
id: 1,
name: 'Website & Email',
description: 'Distributes conversations evenly among available agents',
assignmentOrder: 'round_robin',
conversationPriority: 'high',
inboxes: [mockInboxes[0], mockInboxes[1]],
isFetchingInboxes: false,
});
const policyB = withCount({
id: 2,
name: 'WhatsApp & Messenger',
description: 'Assigns based on capacity and workload',
assignmentOrder: 'capacity_based',
conversationPriority: 'medium',
inboxes: [mockInboxes[2], mockInboxes[3]],
isFetchingInboxes: false,
});
const emptyPolicy = withCount({
id: 3,
name: 'No Inboxes Yet',
description: 'Policy with no assigned inboxes',
assignmentOrder: 'manual',
conversationPriority: 'low',
inboxes: [],
isFetchingInboxes: false,
});
const onEdit = id => console.log('Edit policy:', id);
const onDelete = id => console.log('Delete policy:', id);
const onFetch = () => console.log('Fetch inboxes');
</script>
<template>
<Story
title="Components/AgentManagementPolicy/AssignmentPolicyCard"
:layout="{ type: 'grid', width: '1200px' }"
>
<Variant title="Three Cards (Two with inboxes, One empty)">
<div class="p-4 bg-n-background">
<div class="grid grid-cols-1 gap-4">
<AssignmentPolicyCard
v-bind="policyA"
@edit="onEdit"
@delete="onDelete"
@fetch-inboxes="onFetch"
/>
<AssignmentPolicyCard
v-bind="policyB"
@edit="onEdit"
@delete="onDelete"
@fetch-inboxes="onFetch"
/>
<AssignmentPolicyCard
v-bind="emptyPolicy"
@edit="onEdit"
@delete="onDelete"
@fetch-inboxes="onFetch"
/>
</div>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,112 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { formatToTitleCase } from 'dashboard/helper/commons';
import Button from 'dashboard/components-next/button/Button.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import CardPopover from '../components/CardPopover.vue';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
description: { type: String, default: '' },
assignmentOrder: { type: String, default: '' },
conversationPriority: { type: String, default: '' },
assignedInboxCount: { type: Number, default: 0 },
inboxes: { type: Array, default: () => [] },
isFetchingInboxes: { type: Boolean, default: false },
});
const emit = defineEmits(['edit', 'delete', 'fetchInboxes']);
const { t } = useI18n();
const inboxes = computed(() => {
return props.inboxes.map(inbox => {
return {
name: inbox.name,
id: inbox.id,
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
};
});
});
const order = computed(() => {
return formatToTitleCase(props.assignmentOrder);
});
const priority = computed(() => {
return formatToTitleCase(props.conversationPriority);
});
const handleEdit = () => {
emit('edit', props.id);
};
const handleDelete = () => {
emit('delete', props.id);
};
const handleFetchInboxes = () => {
if (props.inboxes?.length > 0) return;
emit('fetchInboxes', props.id);
};
</script>
<template>
<CardLayout class="[&>div]:px-5">
<div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3">
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<CardPopover
:title="
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER')
"
icon="i-lucide-inbox"
:count="assignedInboxCount"
:items="inboxes"
:is-fetching="isFetchingInboxes"
@fetch="handleFetchInboxes"
/>
</div>
<div class="flex items-center gap-2">
<Button
:label="
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.EDIT')
"
sm
slate
link
class="px-2"
@click="handleEdit"
/>
<div v-if="order" class="w-px h-2.5 bg-n-slate-5" />
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
{{ description }}
</p>
<div class="flex items-center gap-3 py-1.5">
<span v-if="order" class="text-n-slate-11 text-body-para">
{{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
}}
<span class="text-n-slate-12">{{ order }}</span>
</span>
<div v-if="order" class="w-px h-3 bg-n-strong" />
<span v-if="priority" class="text-n-slate-11 text-body-para">
{{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
}}
<span class="text-n-slate-12">{{ priority }}</span>
</span>
</div>
</div>
</CardLayout>
</template>

View File

@@ -0,0 +1,169 @@
<script setup>
import { computed, ref } from 'vue';
import { useToggle, useWindowSize, useElementBounding } 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';
const props = defineProps({
label: {
type: String,
default: '',
},
searchPlaceholder: {
type: String,
default: '',
},
items: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['add']);
const BUFFER_SPACE = 20;
const [showPopover, togglePopover] = useToggle();
const buttonRef = ref();
const dropdownRef = ref();
const searchValue = ref('');
const { width: windowWidth, height: windowHeight } = useWindowSize();
const {
top: buttonTop,
left: buttonLeft,
width: buttonWidth,
height: buttonHeight,
} = useElementBounding(buttonRef);
const { width: dropdownWidth, height: dropdownHeight } =
useElementBounding(dropdownRef);
const filteredItems = computed(() => {
if (!searchValue.value) return props.items;
const query = searchValue.value.toLowerCase();
return picoSearch(props.items, query, ['name']);
});
const handleAdd = item => {
emit('add', item);
togglePopover(false);
};
const shouldShowAbove = computed(() => {
if (!buttonRef.value || !dropdownRef.value) return false;
const spaceBelow =
windowHeight.value - (buttonTop.value + buttonHeight.value);
const spaceAbove = buttonTop.value;
return (
spaceBelow < dropdownHeight.value + BUFFER_SPACE && spaceAbove > spaceBelow
);
});
const shouldAlignRight = computed(() => {
if (!buttonRef.value || !dropdownRef.value) return false;
const spaceRight = windowWidth.value - buttonLeft.value;
const spaceLeft = buttonLeft.value + buttonWidth.value;
return (
spaceRight < dropdownWidth.value + BUFFER_SPACE && spaceLeft > spaceRight
);
});
const handleClickOutside = () => {
if (showPopover.value) {
togglePopover(false);
}
};
</script>
<template>
<div
v-on-click-outside="handleClickOutside"
class="relative flex items-center group"
>
<Button
ref="buttonRef"
slate
type="button"
icon="i-lucide-plus"
sm
:label="label"
@click="togglePopover(!showPopover)"
/>
<div
v-if="showPopover"
ref="dropdownRef"
class="z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
:class="[
shouldShowAbove ? 'bottom-full mb-2' : 'top-full mt-2',
shouldAlignRight ? 'right-0' : 'left-0',
]"
>
<div class="flex flex-col divide-y divide-n-slate-4 w-full">
<Input
v-model="searchValue"
:placeholder="searchPlaceholder"
custom-input-class="bg-transparent !outline-none w-full ltr:!pl-10 rtl:!pr-10 h-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
<div
v-for="item in filteredItems"
:key="item.id"
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"
/>
<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 flex-1">
<div class="flex items-center gap-1 min-w-0 w-full">
<span
:title="item.name || item.title"
class="text-sm text-n-slate-12 truncate min-w-0 flex-1"
>
{{ item.name || item.title }}
</span>
</div>
<span
v-if="item.email || item.phoneNumber"
:title="item.email || item.phoneNumber"
class="text-sm text-n-slate-11 truncate min-w-0 w-full block"
>
{{ item.email || item.phoneNumber }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,127 @@
<script setup>
import { computed, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
defineProps({
nameLabel: {
type: String,
default: '',
},
namePlaceholder: {
type: String,
default: '',
},
descriptionLabel: {
type: String,
default: '',
},
descriptionPlaceholder: {
type: String,
default: '',
},
statusLabel: {
type: String,
default: '',
},
statusPlaceholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['validationChange']);
const policyName = defineModel('policyName', {
type: String,
default: '',
});
const description = defineModel('description', {
type: String,
default: '',
});
const enabled = defineModel('enabled', {
type: Boolean,
default: true,
});
const validationRules = {
policyName: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, { policyName, description });
const isValid = computed(() => !v$.value.$invalid);
watch(
isValid,
() => {
emit('validationChange', {
isValid: isValid.value,
section: 'baseInfo',
});
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-4 pb-4">
<!-- Policy Name Field -->
<div class="flex items-center gap-6">
<WithLabel
:label="nameLabel"
name="policyName"
class="flex items-center w-full [&>label]:min-w-[120px]"
>
<div class="flex-1">
<Input
v-model="policyName"
type="text"
:placeholder="namePlaceholder"
/>
</div>
</WithLabel>
</div>
<!-- Description Field -->
<div class="flex items-center gap-6">
<WithLabel
:label="descriptionLabel"
name="description"
class="flex items-center w-full [&>label]:min-w-[120px]"
>
<div class="flex-1">
<Input
v-model="description"
type="text"
:placeholder="descriptionPlaceholder"
/>
</div>
</WithLabel>
</div>
<!-- Status Field -->
<div v-if="statusLabel" class="flex items-center gap-6">
<WithLabel
:label="statusLabel"
name="enabled"
class="flex items-center w-full [&>label]:min-w-[120px]"
>
<div class="flex items-center gap-2">
<Switch v-model="enabled" />
<span class="text-sm text-n-slate-11">
{{ statusPlaceholder }}
</span>
</div>
</WithLabel>
</div>
</div>
</template>

View File

@@ -0,0 +1,121 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
defineProps({
count: {
type: Number,
default: 0,
},
title: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
items: {
type: Array,
default: () => [],
},
isFetching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['fetch']);
const [showPopover, togglePopover] = useToggle();
const handleButtonClick = () => {
emit('fetch');
togglePopover(!showPopover.value);
};
const handleClickOutside = () => {
if (showPopover.value) {
togglePopover(false);
}
};
</script>
<template>
<div
v-on-click-outside="handleClickOutside"
class="relative flex items-center group"
>
<button
v-if="count"
class="h-6 px-2 rounded-md bg-n-alpha-2 gap-1.5 flex items-center"
@click="handleButtonClick()"
>
<Icon :icon="icon" class="size-3.5 text-n-slate-12" />
<span class="text-n-slate-12 text-sm">
{{ count }}
</span>
</button>
<div
v-if="showPopover"
class="top-full mt-1 ltr:left-0 rtl:right-0 z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak p-3 rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto"
>
<div class="flex items-center gap-2.5 pb-2">
<Icon :icon="icon" class="size-3.5" />
<span class="text-sm text-n-slate-12 font-medium">{{ title }}</span>
</div>
<div
v-if="isFetching"
class="flex items-center justify-center py-3 w-full text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-4 w-full">
<div
v-for="item in items"
:key="item.id"
class="flex items-center justify-between gap-2 min-w-0 w-full"
>
<div class="flex items-center gap-2 min-w-0 w-full">
<Icon
v-if="item.icon"
: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
/>
<div class="flex items-center gap-1 min-w-0 flex-1">
<span
:title="item.name"
class="text-sm text-n-slate-12 truncate min-w-0"
>
{{ item.name }}
</span>
<span
v-if="item.id"
class="text-sm text-n-slate-11 flex-shrink-0"
>
{{ `#${item.id}` }}
</span>
</div>
</div>
<span v-if="item.email" class="text-sm text-n-slate-11 flex-shrink-0">
{{ item.email }}
</span>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,104 @@
<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';
defineProps({
items: {
type: Array,
default: () => [],
},
isFetching: {
type: Boolean,
default: false,
},
emptyStateMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['delete', 'navigate']);
const handleDelete = itemId => {
emit('delete', itemId);
};
const handleNavigate = item => {
emit('navigate', item);
};
</script>
<template>
<div
v-if="isFetching"
class="flex items-center justify-center py-3 w-full text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="items.length === 0 && emptyStateMessage"
class="custom-dashed-border flex items-center justify-center py-6 w-full"
>
<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"
: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"
>
<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
v-if="item.icon"
: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 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
>
{{ item.name }}
</span>
<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">
<span
:title="item.email || item.phoneNumber"
class="text-sm text-n-slate-12 truncate min-w-0"
>
{{ item.email || item.phoneNumber }}
</span>
</div>
<div class="col-span-1 justify-end flex items-center">
<Button
icon="i-lucide-trash"
slate
ghost
sm
type="button"
@click="handleDelete(item.id)"
/>
</div>
</div>
</div>
</template>

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-11 [&>div]:min-w-64"
@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,100 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const { t } = useI18n();
const fairDistributionLimit = defineModel('fairDistributionLimit', {
type: Number,
default: 100,
set(value) {
return Number(value) || 0;
},
});
// 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', {
type: Number,
default: 3600,
set(value) {
return Number(value) || 0;
},
});
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 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;
};
onMounted(() => {
windowUnit.value = detectUnit(windowInMinutes.value);
});
</script>
<template>
<div
class="flex items-start xl:items-center flex-col md:flex-row gap-4 lg:gap-3 bg-n-solid-1 p-4 outline outline-1 outline-n-weak rounded-xl"
>
<div class="flex items-center gap-3">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.INPUT_MAX'
)
}}
</label>
<div class="flex-1">
<Input
v-model="fairDistributionLimit"
type="number"
placeholder="100"
max="100000"
class="w-full"
/>
</div>
</div>
<div class="flex sm:flex-row flex-col items-start sm:items-center gap-4">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.DURATION'
)
}}
</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 (in minutes) -->
<DurationInput
v-model:model-value="windowInMinutes"
v-model:unit="windowUnit"
:min="10"
:max="1438560"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,177 @@
<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 flex-col xs:flex-row items-stretch gap-3"
>
<div
class="flex items-center rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full min-w-0"
:title="getInboxName(limit.inboxId)"
>
<span class="truncate min-w-0">
{{ getInboxName(limit.inboxId) }}
</span>
</div>
<div class="flex items-center gap-3 w-full xs:w-auto">
<div
class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-1 xs:flex-shrink-0 flex items-center min-w-0"
:class="[
!isLimitValid(limit) ? 'outline-n-ruby-8' : 'outline-n-weak',
]"
>
<label
class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2 truncate min-w-0 flex-shrink"
:title="
t(
`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
)
"
>
{{
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 min-w-16 w-24 text-sm flex-shrink-0"
: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>
</div>
</template>

View File

@@ -0,0 +1,79 @@
<script setup>
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
id: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
disabledLabel: {
type: String,
default: '',
},
disabledMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['select']);
const handleChange = () => {
if (!props.isActive && !props.disabled) {
emit('select', props.id);
}
};
</script>
<template>
<div
class="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="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]"
@click="handleChange"
>
<div class="flex flex-col gap-2 items-start">
<div class="flex items-center justify-between w-full gap-3">
<div class="flex items-center gap-2">
<h3 class="text-heading-3 text-n-slate-12">
{{ label }}
</h3>
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
</div>
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
@change="handleChange"
/>
</div>
<p class="text-body-main text-n-slate-11">
{{ disabled && disabledMessage ? disabledMessage : description }}
</p>
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,92 @@
<script setup>
import AddDataDropdown from '../AddDataDropdown.vue';
const mockInboxes = [
{
id: 1,
name: 'Website Support',
email: 'support@company.com',
icon: 'i-lucide-globe',
},
{
id: 2,
name: 'Email Support',
email: 'help@company.com',
icon: 'i-lucide-mail',
},
{
id: 3,
name: 'WhatsApp Business',
phoneNumber: '+1 555-0123',
icon: 'i-lucide-message-circle',
},
{
id: 4,
name: 'Facebook Messenger',
email: 'messenger@company.com',
icon: 'i-lucide-facebook',
},
{
id: 5,
name: 'Twitter DM',
email: 'twitter@company.com',
icon: 'i-lucide-twitter',
},
];
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);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/AddDataDropdown"
:layout="{ type: 'grid', width: '500px' }"
>
<Variant title="Basic Usage - Inboxes">
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
<AddDataDropdown
label="Add Inbox"
search-placeholder="Search inboxes..."
:items="mockInboxes"
@add="handleAdd"
/>
</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

@@ -0,0 +1,29 @@
<script setup>
import { ref } from 'vue';
import BaseInfo from '../BaseInfo.vue';
const policyName = ref('Round Robin Policy');
const description = ref(
'Distributes conversations evenly among available agents'
);
</script>
<template>
<Story
title="Components/AgentManagementPolicy/BaseInfo"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background">
<BaseInfo
v-model:policy-name="policyName"
v-model:description="description"
name-label="Policy Name"
name-placeholder="Enter policy name"
description-label="Description"
description-placeholder="Enter policy description"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,89 @@
<script setup>
import CardPopover from '../CardPopover.vue';
const mockItems = [
{
id: 1,
name: 'Website Support',
icon: 'i-lucide-globe',
},
{
id: 2,
name: 'Email Support',
icon: 'i-lucide-mail',
},
{
id: 3,
name: 'WhatsApp Business',
icon: 'i-lucide-message-circle',
},
{
id: 4,
name: 'Facebook Messenger',
icon: 'i-lucide-facebook',
},
];
const mockUsers = [
{
id: 1,
name: 'John Smith',
email: 'john.smith@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=1',
},
{
id: 2,
name: 'Sarah Johnson',
email: 'sarah.johnson@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=2',
},
{
id: 3,
name: 'Mike Chen',
email: 'mike.chen@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=3',
},
{
id: 4,
name: 'Emily Davis',
email: 'emily.davis@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=4',
},
{
id: 5,
name: 'Alex Rodriguez',
email: 'alex.rodriguez@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=5',
},
];
</script>
<template>
<Story
title="Components/AgentManagementPolicy/CardPopover"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background flex gap-4 h-96 items-start">
<CardPopover
:count="3"
title="Added Inboxes"
icon="i-lucide-inbox"
:items="mockItems.slice(0, 3)"
@fetch="() => console.log('Fetch triggered')"
/>
</div>
</Variant>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background flex gap-4 h-96 items-start">
<CardPopover
:count="3"
title="Added Agents"
icon="i-lucide-users-round"
:items="mockUsers.slice(0, 3)"
@fetch="() => console.log('Fetch triggered')"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,87 @@
<script setup>
import DataTable from '../DataTable.vue';
const mockItems = [
{
id: 1,
name: 'Website Support',
email: 'support@company.com',
icon: 'i-lucide-globe',
},
{
id: 2,
name: 'Email Support',
email: 'help@company.com',
icon: 'i-lucide-mail',
},
{
id: 3,
name: 'WhatsApp Business',
phoneNumber: '+1 555-0123',
icon: 'i-lucide-message-circle',
},
];
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);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/DataTable"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With Data">
<div class="p-8 bg-n-background">
<DataTable
:items="mockItems"
:is-fetching="false"
@delete="handleDelete"
/>
</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" />
</div>
</Variant>
<Variant title="Empty State">
<div class="p-8 bg-n-background">
<DataTable
:items="[]"
:is-fetching="false"
empty-state-message="No items found"
@delete="handleDelete"
/>
</div>
</Variant>
</Story>
</template>

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,25 @@
<script setup>
import { ref } from 'vue';
import FairDistribution from '../FairDistribution.vue';
const fairDistributionLimit = ref(100);
const fairDistributionWindow = ref(3600);
const windowUnit = ref('minutes');
</script>
<template>
<Story
title="Components/AgentManagementPolicy/FairDistribution"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background">
<FairDistribution
v-model:fair-distribution-limit="fairDistributionLimit"
v-model:fair-distribution-window="fairDistributionWindow"
v-model:window-unit="windowUnit"
/>
</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>

View File

@@ -0,0 +1,61 @@
<script setup>
import { ref } from 'vue';
import RadioCard from '../RadioCard.vue';
const selectedOption = ref('round_robin');
const handleSelect = value => {
selectedOption.value = value;
console.log('Selected:', value);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/RadioCard"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background space-y-4">
<RadioCard
id="round_robin"
label="Round Robin"
description="Distributes conversations evenly among all available agents in a rotating manner"
:is-active="selectedOption === 'round_robin'"
@select="handleSelect"
/>
<RadioCard
id="balanced"
label="Balanced Assignment"
description="Assigns conversations based on agent workload to maintain balance"
:is-active="selectedOption === 'balanced'"
@select="handleSelect"
/>
</div>
</Variant>
<Variant title="Active State">
<div class="p-8 bg-n-background">
<RadioCard
id="active_option"
label="Active Option"
description="This option is currently selected and active"
is-active
@select="handleSelect"
/>
</div>
</Variant>
<Variant title="Inactive State">
<div class="p-8 bg-n-background">
<RadioCard
id="inactive_option"
label="Inactive Option"
description="This option is not selected and can be clicked to activate"
is-active
@select="handleSelect"
/>
</div>
</Variant>
</Story>
</template>