add assignment v2 UI
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const settingItems = [
|
||||
{
|
||||
name: 'assignment_policies',
|
||||
title: 'Assignment Policies',
|
||||
description: 'Configure how conversations are assigned to agents',
|
||||
icon: 'arrow-shuffle',
|
||||
route: 'assignment_policies_list',
|
||||
color: 'text-violet-800',
|
||||
bgColor: 'bg-violet-100',
|
||||
},
|
||||
{
|
||||
name: 'leave_management',
|
||||
title: 'Leave Management',
|
||||
description: 'Manage agent leaves and time off requests',
|
||||
icon: 'calendar-event',
|
||||
route: 'assignment_leaves_list',
|
||||
color: 'text-orange-800',
|
||||
bgColor: 'bg-orange-100',
|
||||
},
|
||||
{
|
||||
name: 'agent_capacity',
|
||||
title: 'Agent Capacity',
|
||||
description: 'Set conversation limits and workload management',
|
||||
icon: 'gauge',
|
||||
route: 'assignment_capacity_list',
|
||||
color: 'text-cyan-800',
|
||||
bgColor: 'bg-cyan-100',
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const visibleSettings = settingItems.filter(
|
||||
item => !item.enterprise || isEnterprise
|
||||
);
|
||||
|
||||
const navigateTo = route => {
|
||||
router.push({ name: route });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
title="Assignment Management"
|
||||
description="Configure assignment policies, manage agent leaves, and set capacity limits"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 p-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="item in visibleSettings"
|
||||
:key="item.name"
|
||||
class="relative p-6 bg-white rounded-xl shadow-sm border border-slate-200 hover:shadow-md transition-all duration-200 cursor-pointer group"
|
||||
@click="navigateTo(item.route)"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="p-3 rounded-lg flex items-center justify-center"
|
||||
:class="[item.bgColor]"
|
||||
>
|
||||
<Icon :icon="item.icon" class="h-6 w-6" :class="[item.color]" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium text-slate-900 mb-1">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-600">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
<span
|
||||
v-if="item.enterprise"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 mt-2"
|
||||
>
|
||||
Enterprise
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-6 right-6 text-slate-400 group-hover:text-slate-600 transition-colors"
|
||||
>
|
||||
<Icon icon="chevron-right" class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,121 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import SettingsContent from '../Wrapper.vue';
|
||||
|
||||
// Import components (to be created)
|
||||
import AssignmentSettings from './Index.vue';
|
||||
import PolicyList from './policies/PolicyList.vue';
|
||||
import PolicyForm from './policies/PolicyForm.vue';
|
||||
import LeaveManagement from './leaves/LeaveManagement.vue';
|
||||
import LeaveForm from './leaves/LeaveForm.vue';
|
||||
import LeaveDetails from './leaves/LeaveDetails.vue';
|
||||
import AgentCapacity from './capacity/AgentCapacity.vue';
|
||||
import CapacityForm from './capacity/CapacityForm.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/assignment'),
|
||||
component: SettingsWrapper,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'assignment_settings',
|
||||
component: AssignmentSettings,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/assignment'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'ASSIGNMENT_SETTINGS.HEADER',
|
||||
icon: 'assignment-alt',
|
||||
showBackButton: true,
|
||||
},
|
||||
children: [
|
||||
// Assignment Policies Routes
|
||||
{
|
||||
path: 'policies',
|
||||
name: 'assignment_policies_list',
|
||||
component: PolicyList,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'policies/new',
|
||||
name: 'assignment_policies_new',
|
||||
component: PolicyForm,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'policies/:id/edit',
|
||||
name: 'assignment_policies_edit',
|
||||
component: PolicyForm,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
// Leave Management Routes
|
||||
{
|
||||
path: 'leaves',
|
||||
name: 'assignment_leaves_list',
|
||||
component: LeaveManagement,
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'leaves/new',
|
||||
name: 'assignment_leaves_new',
|
||||
component: LeaveForm,
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'leaves/:id',
|
||||
name: 'assignment_leaves_details',
|
||||
component: LeaveDetails,
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
},
|
||||
// Agent Capacity Routes (Enterprise)
|
||||
{
|
||||
path: 'capacity',
|
||||
name: 'assignment_capacity_list',
|
||||
component: AgentCapacity,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
enterprise: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'capacity/new',
|
||||
name: 'assignment_capacity_new',
|
||||
component: CapacityForm,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
enterprise: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'capacity/:id/edit',
|
||||
name: 'assignment_capacity_edit',
|
||||
component: CapacityForm,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
enterprise: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, h } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import {
|
||||
useVueTable,
|
||||
createColumnHelper,
|
||||
getCoreRowModel,
|
||||
} from '@tanstack/vue-table';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import BasePaywallModal from '../../components/BasePaywallModal.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Table from 'dashboard/components/table/Table.vue';
|
||||
import ConfirmationModal from 'dashboard/components/widgets/modal/ConfirmationModal.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const policies = computed(
|
||||
() => getters['agentCapacity/getCapacityPolicies'].value
|
||||
);
|
||||
// Removed agentCapacities as agents tab is removed
|
||||
const uiFlags = computed(() => getters['agentCapacity/getUIFlags'].value);
|
||||
// Removed agents computed property as agents tab is removed
|
||||
|
||||
// Removed activeTab and tabs since we only have policies now
|
||||
const loading = ref({});
|
||||
const showDeletePopup = ref(false);
|
||||
const showPaywallModal = ref(false);
|
||||
const selectedPolicy = ref(null);
|
||||
|
||||
// Column helpers for TanStack Table
|
||||
const policyColumnHelper = createColumnHelper();
|
||||
const policyColumns = [
|
||||
policyColumnHelper.accessor('name', {
|
||||
header: 'Policy Name',
|
||||
cell: info =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'font-medium text-slate-900' },
|
||||
info.getValue() || 'Untitled Policy'
|
||||
),
|
||||
size: 250,
|
||||
}),
|
||||
policyColumnHelper.accessor('description', {
|
||||
header: 'Description',
|
||||
cell: info =>
|
||||
h('div', { class: 'text-sm text-slate-600' }, info.getValue() || '-'),
|
||||
size: 350,
|
||||
}),
|
||||
policyColumnHelper.accessor('inbox_limits', {
|
||||
header: 'Inbox Limits',
|
||||
cell: info => {
|
||||
const inboxLimits = info.getValue() || [];
|
||||
const count = Array.isArray(inboxLimits) ? inboxLimits.length : 0;
|
||||
return h('span', { class: 'font-medium' }, count);
|
||||
},
|
||||
size: 150,
|
||||
}),
|
||||
policyColumnHelper.accessor('user_count', {
|
||||
header: 'Assigned Agents',
|
||||
cell: info =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class:
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800',
|
||||
},
|
||||
`${info.getValue() || 0} ${info.getValue() === 1 ? 'Agent' : 'Agents'}`
|
||||
),
|
||||
size: 150,
|
||||
}),
|
||||
policyColumnHelper.accessor('actions', {
|
||||
header: 'Actions',
|
||||
cell: info =>
|
||||
h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
icon: 'edit',
|
||||
onClick: () => editPolicy(info.row.original),
|
||||
},
|
||||
() => 'Edit'
|
||||
),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
color: 'ruby',
|
||||
size: 'sm',
|
||||
icon: 'delete',
|
||||
isLoading: loading.value[info.row.original.id],
|
||||
onClick: () => openDeletePopup(info.row.original),
|
||||
},
|
||||
() => 'Delete'
|
||||
),
|
||||
]),
|
||||
size: 100,
|
||||
}),
|
||||
];
|
||||
|
||||
// Removed agent columns as agents tab is removed
|
||||
|
||||
const fetchData = async () => {
|
||||
// Only fetch policies since agents tab is removed
|
||||
await store.dispatch('agentCapacity/get');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
|
||||
const navigateToNew = () => {
|
||||
router.push({ name: 'assignment_capacity_new' });
|
||||
};
|
||||
|
||||
const editPolicy = policy => {
|
||||
router.push({
|
||||
name: 'assignment_capacity_edit',
|
||||
params: { id: policy.id },
|
||||
});
|
||||
};
|
||||
|
||||
const openDeletePopup = policy => {
|
||||
selectedPolicy.value = policy;
|
||||
showDeletePopup.value = true;
|
||||
};
|
||||
|
||||
const closeDeletePopup = () => {
|
||||
selectedPolicy.value = null;
|
||||
showDeletePopup.value = false;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedPolicy.value) return;
|
||||
|
||||
try {
|
||||
loading.value[selectedPolicy.value.id] = true;
|
||||
await store.dispatch('agentCapacity/delete', selectedPolicy.value.id);
|
||||
useAlert('Capacity policy deleted successfully');
|
||||
closeDeletePopup();
|
||||
} catch (error) {
|
||||
useAlert('Failed to delete capacity policy');
|
||||
} finally {
|
||||
loading.value[selectedPolicy.value.id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Removed utilization helper functions as agents tab is removed
|
||||
|
||||
// Table instances
|
||||
const policyTable = computed(() =>
|
||||
useVueTable({
|
||||
data: policies.value,
|
||||
columns: policyColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
);
|
||||
|
||||
// Removed agentTable as agents tab is removed
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
title="Agent Capacity Management"
|
||||
description="Set conversation limits and manage agent workload policies"
|
||||
back-button-label="Back to Assignment"
|
||||
@back="$router.push({ name: 'assignment_index' })"
|
||||
>
|
||||
<template #actions>
|
||||
<Button variant="primary" icon="add" @click="navigateToNew">
|
||||
{{ $t('ASSIGNMENT_SETTINGS.CAPACITY.NEW_BUTTON') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
|
||||
<div class="p-8">
|
||||
<!-- Removed tab switching UI since we only have policies now -->
|
||||
|
||||
<div
|
||||
v-if="uiFlags.isFetching"
|
||||
class="flex items-center justify-center h-64"
|
||||
>
|
||||
<Spinner :size="48" />
|
||||
</div>
|
||||
|
||||
<!-- Policies -->
|
||||
<div v-else>
|
||||
<EmptyState
|
||||
v-if="!policies.length"
|
||||
title="No Capacity Policies"
|
||||
message="Create capacity policies to manage agent workload and conversation limits"
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
icon="add"
|
||||
@click="navigateToNew"
|
||||
>
|
||||
Create Capacity Policy
|
||||
</Button>
|
||||
</EmptyState>
|
||||
|
||||
<Table v-else :table="policyTable" />
|
||||
</div>
|
||||
|
||||
<!-- Agents Tab removed as requested -->
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
v-model:show="showDeletePopup"
|
||||
title="Delete Capacity Policy"
|
||||
:description="`Are you sure you want to delete the capacity policy '${selectedPolicy?.name}'? This action cannot be undone.`"
|
||||
confirm-label="Delete"
|
||||
cancel-label="Cancel"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="closeDeletePopup"
|
||||
/>
|
||||
|
||||
<!-- Removed BasePaywallModal for Enterprise accounts -->
|
||||
</div>
|
||||
</template>
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import BasePaywallModal from '../../components/BasePaywallModal.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components/widgets/forms/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const isEditMode = computed(() => !!route.params.id);
|
||||
const policyId = computed(() => route.params.id);
|
||||
|
||||
const uiFlags = computed(() => getters['agentCapacity/getUIFlags'].value);
|
||||
const agents = computed(() => getters['agents/getAgents'].value);
|
||||
const inboxes = computed(() => getters['inboxes/getInboxes'].value);
|
||||
|
||||
const loading = ref(false);
|
||||
const showPaywallModal = ref(false);
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
exclusion_rules: {
|
||||
labels: [],
|
||||
hours_threshold: 24,
|
||||
},
|
||||
inbox_limits: [], // Array of { inbox_id, conversation_limit }
|
||||
selected_agents: [], // For MultiSelect component
|
||||
});
|
||||
|
||||
const errors = ref({});
|
||||
|
||||
const addInboxLimit = () => {
|
||||
formData.value.inbox_limits.push({
|
||||
inbox_id: '',
|
||||
conversation_limit: 10,
|
||||
});
|
||||
};
|
||||
|
||||
const removeInboxLimit = index => {
|
||||
formData.value.inbox_limits.splice(index, 1);
|
||||
};
|
||||
|
||||
const loadPolicy = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const policy = await store.dispatch('agentCapacity/show', policyId.value);
|
||||
formData.value = {
|
||||
name: policy.name,
|
||||
description: policy.description || '',
|
||||
exclusion_rules: policy.exclusion_rules || {
|
||||
labels: [],
|
||||
hours_threshold: 24,
|
||||
},
|
||||
inbox_limits: Array.isArray(policy.inbox_limits)
|
||||
? policy.inbox_limits
|
||||
: [],
|
||||
selected_agents:
|
||||
policy.users?.map(user => ({ id: user.id, name: user.name })) || [],
|
||||
};
|
||||
} catch (error) {
|
||||
useAlert('Failed to load capacity policy');
|
||||
router.push({ name: 'assignment_capacity_list' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
errors.value = {};
|
||||
|
||||
if (!formData.value.name.trim()) {
|
||||
errors.value.name = 'Policy name is required';
|
||||
}
|
||||
|
||||
if (!formData.value.selected_agents.length) {
|
||||
errors.value.selected_agents = 'At least one agent must be selected';
|
||||
}
|
||||
|
||||
// Validate inbox limits
|
||||
for (let i = 0; i < formData.value.inbox_limits.length; i++) {
|
||||
const limit = formData.value.inbox_limits[i];
|
||||
if (!limit.inbox_id) {
|
||||
errors.value[`inbox_limit_${i}_inbox`] = 'Please select an inbox';
|
||||
}
|
||||
if (!limit.conversation_limit || limit.conversation_limit < 1) {
|
||||
errors.value[`inbox_limit_${i}_limit`] =
|
||||
'Conversation limit must be at least 1';
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(errors.value).length === 0;
|
||||
};
|
||||
|
||||
const savePolicy = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: formData.value.name.trim(),
|
||||
description: formData.value.description.trim(),
|
||||
exclusion_rules: formData.value.exclusion_rules,
|
||||
};
|
||||
|
||||
let policyToUse;
|
||||
if (isEditMode.value) {
|
||||
// Update policy
|
||||
policyToUse = await store.dispatch('agentCapacity/update', {
|
||||
id: policyId.value,
|
||||
...payload,
|
||||
});
|
||||
useAlert('Capacity policy updated successfully');
|
||||
} else {
|
||||
// Create policy
|
||||
policyToUse = await store.dispatch('agentCapacity/create', payload);
|
||||
useAlert('Capacity policy created successfully');
|
||||
}
|
||||
|
||||
const actualPolicyId = policyToUse?.id || policyId.value;
|
||||
|
||||
// Handle inbox limits
|
||||
if (isEditMode.value) {
|
||||
// For editing, handle both additions and removals
|
||||
const updatedPolicy = await store.dispatch(
|
||||
'agentCapacity/show',
|
||||
actualPolicyId
|
||||
);
|
||||
const existingInboxIds =
|
||||
updatedPolicy.inbox_limits?.map(limit => limit.inbox_id) || [];
|
||||
const currentInboxIds = formData.value.inbox_limits
|
||||
.map(limit => limit.inbox_id)
|
||||
.filter(id => id);
|
||||
|
||||
// Remove inbox limits that are no longer configured
|
||||
const inboxesToRemove = existingInboxIds.filter(
|
||||
inboxId => !currentInboxIds.includes(inboxId)
|
||||
);
|
||||
for (const inboxId of inboxesToRemove) {
|
||||
await store.dispatch('agentCapacity/removeInboxLimit', {
|
||||
id: actualPolicyId,
|
||||
inboxId: inboxId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set/update inbox limits
|
||||
for (const limit of formData.value.inbox_limits) {
|
||||
if (limit.inbox_id && limit.conversation_limit) {
|
||||
await store.dispatch('agentCapacity/setInboxLimit', {
|
||||
id: actualPolicyId,
|
||||
inboxId: limit.inbox_id,
|
||||
conversationLimit: limit.conversation_limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle agent assignments
|
||||
const currentAgentIds = formData.value.selected_agents.map(
|
||||
agent => agent.id
|
||||
);
|
||||
|
||||
if (isEditMode.value) {
|
||||
// For editing, we need to handle both additions and removals
|
||||
// Get the current policy state to compare
|
||||
const updatedPolicy = await store.dispatch(
|
||||
'agentCapacity/show',
|
||||
actualPolicyId
|
||||
);
|
||||
const existingAgentIds = updatedPolicy.users?.map(user => user.id) || [];
|
||||
|
||||
// Remove agents that are no longer selected
|
||||
const agentsToRemove = existingAgentIds.filter(
|
||||
agentId => !currentAgentIds.includes(agentId)
|
||||
);
|
||||
for (const agentId of agentsToRemove) {
|
||||
await store.dispatch('agentCapacity/removeUser', {
|
||||
id: actualPolicyId,
|
||||
userId: agentId,
|
||||
});
|
||||
}
|
||||
|
||||
// Add newly selected agents
|
||||
const agentsToAdd = currentAgentIds.filter(
|
||||
agentId => !existingAgentIds.includes(agentId)
|
||||
);
|
||||
for (const agentId of agentsToAdd) {
|
||||
await store.dispatch('agentCapacity/assignUser', {
|
||||
id: actualPolicyId,
|
||||
userId: agentId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For new policies, just assign all selected agents
|
||||
for (const agentId of currentAgentIds) {
|
||||
await store.dispatch('agentCapacity/assignUser', {
|
||||
id: actualPolicyId,
|
||||
userId: agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the capacity policies list to show updated data
|
||||
await store.dispatch('agentCapacity/get');
|
||||
router.push({ name: 'assignment_capacity_list' });
|
||||
} catch (error) {
|
||||
const message = isEditMode.value
|
||||
? 'Failed to update capacity policy'
|
||||
: 'Failed to create capacity policy';
|
||||
useAlert(message);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
router.push({ name: 'assignment_capacity_list' });
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
store.dispatch('agents/get'),
|
||||
store.dispatch('inboxes/get'),
|
||||
]);
|
||||
|
||||
if (isEditMode.value) {
|
||||
await loadPolicy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
:title="isEditMode ? 'Edit Capacity Policy' : 'Create Capacity Policy'"
|
||||
description="Configure conversation limits and agent assignments for this policy"
|
||||
back-button-label="Back to Capacity Management"
|
||||
@back="cancel"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center h-64">
|
||||
<Spinner :size="48" />
|
||||
</div>
|
||||
|
||||
<div v-else class="max-w-3xl p-8">
|
||||
<form @submit.prevent="savePolicy">
|
||||
<div class="space-y-6">
|
||||
<!-- Basic Information -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Basic Information</h3>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
v-model="formData.name"
|
||||
label="Policy Name"
|
||||
placeholder="e.g., Standard Agent Capacity"
|
||||
:error="errors.name"
|
||||
required
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="formData.description"
|
||||
label="Description"
|
||||
placeholder="Describe the purpose of this capacity policy"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inbox-Specific Limits -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium">Inbox Capacity Limits</h3>
|
||||
<Button variant="ghost" icon="add" @click="addInboxLimit">
|
||||
Add Inbox Limit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!formData.inbox_limits.length"
|
||||
class="text-slate-500 text-sm text-center py-8"
|
||||
>
|
||||
No inbox limits configured. Click "Add Inbox Limit" to set
|
||||
capacity limits for specific inboxes.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="(limit, index) in formData.inbox_limits"
|
||||
:key="index"
|
||||
class="flex items-end gap-4 p-4 border border-slate-200 rounded-lg"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium mb-1">Inbox</label>
|
||||
<select
|
||||
v-model="limit.inbox_id"
|
||||
class="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-woot-500"
|
||||
:class="{
|
||||
'border-red-500': errors[`inbox_limit_${index}_inbox`],
|
||||
}"
|
||||
>
|
||||
<option value="">Select an inbox</option>
|
||||
<option
|
||||
v-for="inbox in inboxes"
|
||||
:key="inbox.id"
|
||||
:value="inbox.id"
|
||||
>
|
||||
{{ inbox.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div
|
||||
v-if="errors[`inbox_limit_${index}_inbox`]"
|
||||
class="text-red-500 text-xs mt-1"
|
||||
>
|
||||
{{ errors[`inbox_limit_${index}_inbox`] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-32">
|
||||
<Input
|
||||
v-model.number="limit.conversation_limit"
|
||||
type="number"
|
||||
label="Limit"
|
||||
placeholder="10"
|
||||
:error="errors[`inbox_limit_${index}_limit`]"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="ruby"
|
||||
icon="delete"
|
||||
@click="removeInboxLimit(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exclusion Rules -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Exclusion Rules</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Exclude conversations with labels</label>
|
||||
<Input
|
||||
v-model="formData.exclusion_rules.labels"
|
||||
placeholder="vip, escalated (comma-separated)"
|
||||
help-text="Conversations with these labels won't count towards capacity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model.number="formData.exclusion_rules.hours_threshold"
|
||||
type="number"
|
||||
label="Exclude conversations older than (hours)"
|
||||
placeholder="24"
|
||||
help-text="Conversations created before this many hours ago won't count towards capacity"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Assignment -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Agent Assignment</h3>
|
||||
<MultiSelect
|
||||
v-model="formData.selected_agents"
|
||||
:options="agents"
|
||||
label="Assigned Agents"
|
||||
placeholder="Select agents to apply this policy"
|
||||
:error="errors.selected_agents"
|
||||
track-by="id"
|
||||
label-key="name"
|
||||
required
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
:src="option.avatar_url"
|
||||
:alt="option.name"
|
||||
class="w-6 h-6 rounded-full"
|
||||
/>
|
||||
<span>{{ option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-4">
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Note:</strong>
|
||||
Agents will be limited by the conversation counts configured in
|
||||
this policy. Conversations matching exclusion rules won't count
|
||||
towards their capacity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-8">
|
||||
<Button variant="ghost" @click="cancel"> Cancel </Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
|
||||
>
|
||||
{{ isEditMode ? 'Update Policy' : 'Create Policy' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Removed BasePaywallModal for Enterprise accounts -->
|
||||
</div>
|
||||
</template>
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
// Badge component doesn't exist - will use inline styling instead
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ApprovalModal from './components/ApprovalModal.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const { isAdmin } = useAdmin();
|
||||
const { currentUserId } = useAccount();
|
||||
|
||||
const leaveId = computed(() => route.params.id);
|
||||
const leave = computed(() => getters['leaves/getLeave'].value(leaveId.value));
|
||||
|
||||
const loading = ref(true);
|
||||
const showApprovalModal = ref(false);
|
||||
const approvalAction = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.dispatch('leaves/show', leaveId.value);
|
||||
} catch (error) {
|
||||
useAlert('Failed to load leave details');
|
||||
router.push({ name: 'assignment_leaves_list' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Removed getStatusBadgeVariant as we're using inline classes now
|
||||
|
||||
const goBack = () => {
|
||||
router.push({ name: 'assignment_leaves_list' });
|
||||
};
|
||||
|
||||
const openApprovalModal = action => {
|
||||
approvalAction.value = action;
|
||||
showApprovalModal.value = true;
|
||||
};
|
||||
|
||||
const closeApprovalModal = () => {
|
||||
approvalAction.value = '';
|
||||
showApprovalModal.value = false;
|
||||
};
|
||||
|
||||
const handleApproval = async notes => {
|
||||
try {
|
||||
if (approvalAction.value === 'approve') {
|
||||
await store.dispatch('leaves/approve', {
|
||||
id: leaveId.value,
|
||||
comments: notes,
|
||||
});
|
||||
useAlert('Leave request approved successfully');
|
||||
} else {
|
||||
await store.dispatch('leaves/reject', {
|
||||
id: leaveId.value,
|
||||
reason: notes,
|
||||
});
|
||||
useAlert('Leave request rejected successfully');
|
||||
}
|
||||
closeApprovalModal();
|
||||
} catch (error) {
|
||||
const message =
|
||||
approvalAction.value === 'approve'
|
||||
? 'Failed to approve leave request'
|
||||
: 'Failed to reject leave request';
|
||||
useAlert(`${message}: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const canApproveReject = computed(() => {
|
||||
return isAdmin.value && leave.value?.status === 'pending';
|
||||
});
|
||||
|
||||
const canDelete = computed(() => {
|
||||
return (
|
||||
leave.value?.status === 'pending' &&
|
||||
(isAdmin.value || leave.value?.agent_id === currentUserId.value)
|
||||
);
|
||||
});
|
||||
|
||||
const deleteLeave = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Are you sure you want to delete this leave request? This action cannot be undone.'
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
await store.dispatch('leaves/delete', leaveId.value);
|
||||
useAlert('Leave request deleted successfully');
|
||||
router.push({ name: 'assignment_leaves_list' });
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
`Failed to delete leave request: ${error.message || 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
title="Leave Details"
|
||||
description="View complete information about this leave request."
|
||||
back-button-label="Back to Leaves"
|
||||
@back="goBack"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center h-64">
|
||||
<Spinner size="large" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="leave" class="max-w-4xl p-8">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200">
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-slate-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<img
|
||||
:src="leave.agent?.avatar_url"
|
||||
:alt="leave.agent?.name"
|
||||
class="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">{{ leave.agent?.name }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ leave.agent?.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
|
||||
:class="[
|
||||
leave.status === 'approved'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: leave.status === 'rejected'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: leave.status === 'cancelled'
|
||||
? 'bg-slate-100 text-slate-800'
|
||||
: 'bg-yellow-100 text-yellow-800',
|
||||
]"
|
||||
>
|
||||
{{ leave.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Leave Type -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-slate-500 mb-1">Leave Type</h3>
|
||||
<p class="text-lg font-medium capitalize">
|
||||
{{ leave.leave_type }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dates -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-slate-500 mb-1">Dates</h3>
|
||||
<p class="text-lg">
|
||||
{{ new Date(leave.start_date).toLocaleDateString() }} -
|
||||
{{ new Date(leave.end_date).toLocaleDateString() }}
|
||||
</p>
|
||||
<p class="text-sm text-slate-600 mt-1">
|
||||
{{
|
||||
leave.total_days ||
|
||||
Math.ceil(
|
||||
(new Date(leave.end_date) - new Date(leave.start_date)) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
) + 1
|
||||
}}
|
||||
days
|
||||
<span v-if="leave.is_half_day">
|
||||
({{ leave.half_day_period }})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-slate-500 mb-1">Reason</h3>
|
||||
<p class="text-base whitespace-pre-wrap">{{ leave.reason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Request Info -->
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-slate-500 mb-1">
|
||||
Requested On
|
||||
</h3>
|
||||
<p class="text-base">
|
||||
{{ new Date(leave.created_at).toLocaleDateString() }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="leave.status !== 'pending'">
|
||||
<h3 class="text-sm font-medium text-slate-500 mb-1">
|
||||
{{
|
||||
leave.status === 'approved' ? 'Approved By' : 'Rejected By'
|
||||
}}
|
||||
</h3>
|
||||
<p class="text-base">
|
||||
{{ leave.approved_by?.name || 'System' }}
|
||||
<span class="text-sm text-slate-600">
|
||||
({{ new Date(leave.updated_at).toLocaleDateString() }})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Removed approver notes section - field doesn't exist in API -->
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="p-6 border-t border-slate-200">
|
||||
<div class="flex justify-between">
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
variant="danger"
|
||||
color-scheme="secondary"
|
||||
icon="delete"
|
||||
@click="deleteLeave"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<div v-if="canApproveReject" class="flex gap-3 ml-auto">
|
||||
<Button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
@click="openApprovalModal('reject')"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button variant="primary" @click="openApprovalModal('approve')">
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApprovalModal
|
||||
v-model:show="showApprovalModal"
|
||||
:action="approvalAction"
|
||||
:leave="leave"
|
||||
@confirm="handleApproval"
|
||||
@cancel="closeApprovalModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
// Use native select element instead
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
// Removed DatePicker - using native HTML date inputs instead
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { currentUserId } = useAccount();
|
||||
|
||||
const formData = ref({
|
||||
agent_id: currentUserId?.value || null,
|
||||
leave_type: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const errors = ref({});
|
||||
const loading = ref(false);
|
||||
|
||||
const leaveTypes = [
|
||||
{ value: 'vacation', label: 'Vacation' },
|
||||
{ value: 'sick', label: 'Sick Leave' },
|
||||
{ value: 'personal', label: 'Personal' },
|
||||
{ value: 'maternity', label: 'Maternity' },
|
||||
{ value: 'paternity', label: 'Paternity' },
|
||||
{ value: 'unpaid', label: 'Unpaid' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
// Removed half day options - not supported by API
|
||||
|
||||
const totalDays = computed(() => {
|
||||
if (!formData.value.start_date || !formData.value.end_date) return 0;
|
||||
|
||||
const start = new Date(formData.value.start_date);
|
||||
const end = new Date(formData.value.end_date);
|
||||
const diffTime = Math.abs(end - start);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
return diffDays;
|
||||
});
|
||||
|
||||
const validateForm = () => {
|
||||
errors.value = {};
|
||||
|
||||
if (!formData.value.leave_type) {
|
||||
errors.value.leave_type = 'Leave type is required';
|
||||
}
|
||||
|
||||
if (!formData.value.start_date) {
|
||||
errors.value.start_date = 'Start date is required';
|
||||
}
|
||||
|
||||
if (!formData.value.end_date) {
|
||||
errors.value.end_date = 'End date is required';
|
||||
}
|
||||
|
||||
if (formData.value.start_date && formData.value.end_date) {
|
||||
const start = new Date(formData.value.start_date);
|
||||
const end = new Date(formData.value.end_date);
|
||||
|
||||
if (start > end) {
|
||||
errors.value.end_date = 'End date must be after start date';
|
||||
}
|
||||
|
||||
// Removed half day validation - not supported by API
|
||||
}
|
||||
|
||||
if (!formData.value.reason) {
|
||||
errors.value.reason = 'Reason is required';
|
||||
}
|
||||
|
||||
return Object.keys(errors.value).length === 0;
|
||||
};
|
||||
|
||||
// Removed handleHalfDayChange - not needed without half day functionality
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
leave: {
|
||||
start_date: formData.value.start_date,
|
||||
end_date: formData.value.end_date,
|
||||
leave_type: formData.value.leave_type,
|
||||
reason: formData.value.reason,
|
||||
},
|
||||
user_id: formData.value.agent_id,
|
||||
};
|
||||
|
||||
await store.dispatch('leaves/create', payload);
|
||||
|
||||
useAlert('Leave request created successfully');
|
||||
router.push({ name: 'assignment_leaves_list' });
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
`Failed to create leave request: ${error.message || 'Unknown error'}`
|
||||
);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
router.push({ name: 'assignment_leaves_list' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
title="Request Leave"
|
||||
description="Create a new leave request. Your manager will be notified for approval."
|
||||
back-button-label="Back to Leaves"
|
||||
@back="cancel"
|
||||
/>
|
||||
|
||||
<div class="max-w-2xl p-8">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 space-y-6"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||
Leave Type
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="formData.leave_type"
|
||||
class="w-full rounded-md border-slate-300 text-sm"
|
||||
:class="{ 'border-red-500': errors.leave_type }"
|
||||
required
|
||||
>
|
||||
<option value="">Select leave type</option>
|
||||
<option
|
||||
v-for="type in leaveTypes"
|
||||
:key="type.value"
|
||||
:value="type.value"
|
||||
>
|
||||
{{ type.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="errors.leave_type" class="mt-1 text-sm text-red-600">
|
||||
{{ errors.leave_type }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Start Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||
Start Date
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.start_date"
|
||||
type="date"
|
||||
class="w-full rounded-md border-slate-300 text-sm"
|
||||
:class="{ 'border-red-500': errors.start_date }"
|
||||
:min="new Date().toISOString().split('T')[0]"
|
||||
required
|
||||
/>
|
||||
<p v-if="errors.start_date" class="mt-1 text-sm text-red-600">
|
||||
{{ errors.start_date }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- End Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||
End Date
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.end_date"
|
||||
type="date"
|
||||
class="w-full rounded-md border-slate-300 text-sm"
|
||||
:class="{ 'border-red-500': errors.end_date }"
|
||||
:min="
|
||||
formData.start_date || new Date().toISOString().split('T')[0]
|
||||
"
|
||||
required
|
||||
/>
|
||||
<p v-if="errors.end_date" class="mt-1 text-sm text-red-600">
|
||||
{{ errors.end_date }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Removed half day functionality - not supported by API -->
|
||||
|
||||
<div>
|
||||
<div class="text-sm text-slate-600 mb-2">
|
||||
Total Days:
|
||||
<span class="font-medium text-slate-900">{{ totalDays }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
v-model="formData.reason"
|
||||
label="Reason"
|
||||
placeholder="Please provide a reason for your leave request"
|
||||
:error="errors.reason"
|
||||
rows="4"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<Button variant="clear" @click="cancel"> Cancel </Button>
|
||||
<Button type="submit" variant="primary" :loading="loading">
|
||||
Submit Request
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+482
@@ -0,0 +1,482 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ConfirmationModal from 'dashboard/components/widgets/modal/ConfirmationModal.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { currentUserId } = useAccount();
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const leaves = computed(() => getters['leaves/getLeaves'].value || []);
|
||||
const uiFlags = computed(() => getters['leaves/getUIFlags'].value || {});
|
||||
|
||||
const activeTab = ref('all');
|
||||
const loading = ref({});
|
||||
const showDeletePopup = ref(false);
|
||||
const selectedLeave = ref(null);
|
||||
|
||||
// Simplified filtered leaves without search
|
||||
const filteredLeaves = computed(() => {
|
||||
if (activeTab.value === 'all') {
|
||||
return leaves.value;
|
||||
}
|
||||
return leaves.value.filter(leave => leave.status === activeTab.value);
|
||||
});
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
key: 'all',
|
||||
label: 'All Leaves',
|
||||
count: leaves.value.length,
|
||||
},
|
||||
{
|
||||
key: 'approved',
|
||||
label: 'Approved Leaves',
|
||||
count: leaves.value.filter(leave => leave.status === 'approved').length,
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: 'Pending Leaves',
|
||||
count: leaves.value.filter(leave => leave.status === 'pending').length,
|
||||
},
|
||||
{
|
||||
key: 'rejected',
|
||||
label: 'Rejected Leaves',
|
||||
count: leaves.value.filter(leave => leave.status === 'rejected').length,
|
||||
},
|
||||
]);
|
||||
|
||||
// Helper functions for card display
|
||||
const getStatusColor = status => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'rejected':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getCompactStatusColor = status => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'bg-green-50 text-green-700 ring-1 ring-inset ring-green-600/20';
|
||||
case 'rejected':
|
||||
return 'bg-red-50 text-red-700 ring-1 ring-inset ring-red-600/20';
|
||||
case 'pending':
|
||||
return 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/20';
|
||||
default:
|
||||
return 'bg-gray-50 text-gray-700 ring-1 ring-inset ring-gray-600/20';
|
||||
}
|
||||
};
|
||||
|
||||
const getCompactLeaveTypeColor = type => {
|
||||
switch (type) {
|
||||
case 'vacation':
|
||||
return 'bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-600/20';
|
||||
case 'sick':
|
||||
return 'bg-red-50 text-red-700 ring-1 ring-inset ring-red-600/20';
|
||||
case 'personal':
|
||||
return 'bg-purple-50 text-purple-700 ring-1 ring-inset ring-purple-600/20';
|
||||
case 'maternity':
|
||||
case 'paternity':
|
||||
return 'bg-pink-50 text-pink-700 ring-1 ring-inset ring-pink-600/20';
|
||||
default:
|
||||
return 'bg-gray-50 text-gray-700 ring-1 ring-inset ring-gray-600/20';
|
||||
}
|
||||
};
|
||||
|
||||
const getLeaveTypeColor = type => {
|
||||
switch (type) {
|
||||
case 'vacation':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'sick':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'personal':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'maternity':
|
||||
case 'paternity':
|
||||
return 'bg-pink-100 text-pink-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateRange = (startDate, endDate) => {
|
||||
const start = new Date(startDate).toLocaleDateString();
|
||||
const end = new Date(endDate).toLocaleDateString();
|
||||
return start === end ? start : `${start} - ${end}`;
|
||||
};
|
||||
|
||||
const formatCompactDateRange = (startDate, endDate) => {
|
||||
const options = { month: 'short', day: 'numeric' };
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const startStr = start.toLocaleDateString('en-US', options);
|
||||
const endStr = end.toLocaleDateString('en-US', options);
|
||||
|
||||
// If same date
|
||||
if (startStr === endStr) {
|
||||
return startStr;
|
||||
}
|
||||
|
||||
// If different years, show year for both
|
||||
if (start.getFullYear() !== end.getFullYear()) {
|
||||
return `${startStr}, ${start.getFullYear()} - ${endStr}, ${end.getFullYear()}`;
|
||||
}
|
||||
|
||||
return `${startStr} - ${endStr}`;
|
||||
};
|
||||
|
||||
const getDaysCount = (startDate, endDate) => {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const diffTime = Math.abs(end - start);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
return diffDays;
|
||||
};
|
||||
|
||||
const fetchLeaves = async () => {
|
||||
// Fetch all leaves without status filter to show all data
|
||||
await store.dispatch('leaves/get', {});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchLeaves();
|
||||
});
|
||||
|
||||
const navigateToNew = () => {
|
||||
router.push({ name: 'assignment_leaves_new' });
|
||||
};
|
||||
|
||||
const viewLeave = leave => {
|
||||
router.push({
|
||||
name: 'assignment_leaves_details',
|
||||
params: { id: leave.id },
|
||||
});
|
||||
};
|
||||
|
||||
const openDeletePopup = leave => {
|
||||
selectedLeave.value = leave;
|
||||
showDeletePopup.value = true;
|
||||
};
|
||||
|
||||
const closeDeletePopup = () => {
|
||||
selectedLeave.value = null;
|
||||
showDeletePopup.value = false;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedLeave.value) return;
|
||||
|
||||
try {
|
||||
loading.value[selectedLeave.value.id] = true;
|
||||
await store.dispatch('leaves/delete', selectedLeave.value.id);
|
||||
useAlert('Leave request deleted successfully');
|
||||
closeDeletePopup();
|
||||
await fetchLeaves(); // Refresh the list
|
||||
} catch (error) {
|
||||
useAlert('Failed to delete leave request');
|
||||
} finally {
|
||||
loading.value[selectedLeave.value.id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get agent name or fallback
|
||||
const getAgentName = leave => {
|
||||
return (
|
||||
leave.user?.name ||
|
||||
leave.agent?.name ||
|
||||
`Agent ${leave.user_id || leave.agent_id || 'Unknown'}`
|
||||
);
|
||||
};
|
||||
|
||||
const getAgentEmail = leave => {
|
||||
return leave.user?.email || leave.agent?.email || '';
|
||||
};
|
||||
|
||||
const getAgentAvatar = leave => {
|
||||
return (
|
||||
leave.user?.avatar_url ||
|
||||
leave.agent?.avatar_url ||
|
||||
'/assets/images/chatwoot_logo.svg'
|
||||
);
|
||||
};
|
||||
|
||||
const canManageLeave = leave => {
|
||||
return isAdmin.value || leave.agent_id === currentUserId.value;
|
||||
};
|
||||
|
||||
const approveLeave = async leave => {
|
||||
if (!window.confirm('Are you sure you want to approve this leave request?'))
|
||||
return;
|
||||
|
||||
try {
|
||||
loading.value[leave.id] = true;
|
||||
await store.dispatch('leaves/approve', {
|
||||
id: leave.id,
|
||||
comments: 'Approved via dashboard',
|
||||
});
|
||||
useAlert('Leave request approved successfully');
|
||||
await fetchLeaves();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
`Failed to approve leave request: ${error.message || 'Unknown error'}`
|
||||
);
|
||||
} finally {
|
||||
loading.value[leave.id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectLeave = async leave => {
|
||||
if (!window.confirm('Are you sure you want to reject this leave request?'))
|
||||
return;
|
||||
|
||||
try {
|
||||
loading.value[leave.id] = true;
|
||||
await store.dispatch('leaves/reject', {
|
||||
id: leave.id,
|
||||
reason: 'Rejected via dashboard',
|
||||
});
|
||||
useAlert('Leave request rejected successfully');
|
||||
await fetchLeaves();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
`Failed to reject leave request: ${error.message || 'Unknown error'}`
|
||||
);
|
||||
} finally {
|
||||
loading.value[leave.id] = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<BaseSettingsHeader
|
||||
title="Leave Management"
|
||||
description="View and manage leave requests for agents. Approved leaves will exclude agents from automatic assignments."
|
||||
back-button-label="Back to Assignment"
|
||||
@back="$router.push({ name: 'assignment_index' })"
|
||||
>
|
||||
<template #actions>
|
||||
<Button variant="primary" icon="add" @click="navigateToNew">
|
||||
Request Leave
|
||||
</Button>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
|
||||
<div class="p-8 space-y-6">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200">
|
||||
<div class="flex border-b border-slate-200">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="flex-1 px-6 py-4 text-sm font-medium text-center transition-all duration-200 relative"
|
||||
:class="[
|
||||
activeTab === tab.key
|
||||
? 'text-blue-600 bg-white'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50',
|
||||
]"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
{{ tab.label }}
|
||||
<span
|
||||
v-if="tab.count > 0"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="
|
||||
activeTab === tab.key
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-slate-100 text-slate-700'
|
||||
"
|
||||
>
|
||||
{{ tab.count }}
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
v-if="activeTab === tab.key"
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="uiFlags.isFetching"
|
||||
class="flex items-center justify-center h-64"
|
||||
>
|
||||
<Spinner :size="48" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<EmptyState
|
||||
v-else-if="!filteredLeaves.length"
|
||||
title="No Leave Requests"
|
||||
message="No leave requests found. Create a new leave request to get started."
|
||||
>
|
||||
<Button variant="solid" size="medium" icon="add" @click="navigateToNew">
|
||||
Request Leave
|
||||
</Button>
|
||||
</EmptyState>
|
||||
|
||||
<!-- Leave Cards - Compact Modern Design -->
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="leave in filteredLeaves"
|
||||
:key="leave.id"
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 hover:shadow-md hover:border-slate-300 transition-all duration-200 cursor-pointer group"
|
||||
@click="viewLeave(leave)"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Avatar -->
|
||||
<img
|
||||
:src="getAgentAvatar(leave)"
|
||||
:alt="getAgentName(leave)"
|
||||
class="w-10 h-10 rounded-full ring-2 ring-white shadow-sm flex-shrink-0"
|
||||
/>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<div class="flex-1">
|
||||
<!-- First Row: Name, Date Range, Days -->
|
||||
<div class="flex items-center gap-2 flex-wrap mb-1">
|
||||
<h3 class="font-semibold text-slate-900">
|
||||
{{ getAgentName(leave) }}
|
||||
</h3>
|
||||
<span class="text-slate-300">•</span>
|
||||
<span class="text-sm text-slate-700">
|
||||
{{
|
||||
formatCompactDateRange(
|
||||
leave.start_date,
|
||||
leave.end_date
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-700"
|
||||
>
|
||||
{{ getDaysCount(leave.start_date, leave.end_date) }}
|
||||
{{
|
||||
getDaysCount(leave.start_date, leave.end_date) === 1
|
||||
? 'day'
|
||||
: 'days'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Second Row: Reason -->
|
||||
<p class="text-sm text-slate-600 line-clamp-1">
|
||||
{{ leave.reason || 'No reason provided' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium capitalize"
|
||||
:class="getCompactLeaveTypeColor(leave.leave_type)"
|
||||
>
|
||||
{{ leave.leave_type }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium capitalize"
|
||||
:class="getCompactStatusColor(leave.status)"
|
||||
>
|
||||
{{ leave.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Row: Meta Info and Actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 text-xs text-slate-500">
|
||||
<span>
|
||||
Requested
|
||||
{{
|
||||
new Date(
|
||||
leave.created_at || leave.start_date
|
||||
).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-if="leave.approved_by?.name">
|
||||
<span class="text-slate-300">•</span>
|
||||
{{
|
||||
leave.status === 'approved' ? 'Approved' : 'Rejected'
|
||||
}}
|
||||
by {{ leave.approved_by.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions (visible on hover) -->
|
||||
<div
|
||||
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<template v-if="leave.status === 'pending' && isAdmin">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="check"
|
||||
color="teal"
|
||||
@click.stop="approveLeave(leave)"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="x"
|
||||
color="ruby"
|
||||
@click.stop="rejectLeave(leave)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
v-if="canManageLeave(leave) && leave.status === 'pending'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="trash"
|
||||
color="ruby"
|
||||
@click.stop="openDeletePopup(leave)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<ConfirmationModal
|
||||
v-model:show="showDeletePopup"
|
||||
title="Delete Leave Request"
|
||||
description="Are you sure you want to delete this leave request? This action cannot be undone."
|
||||
confirm-label="Delete"
|
||||
cancel-label="Cancel"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="closeDeletePopup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Modal from 'dashboard/components/Modal.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
action: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['approve', 'reject'].includes(value),
|
||||
},
|
||||
leave: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'confirm', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const notes = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
// Reset notes when modal opens/closes
|
||||
watch(
|
||||
() => props.show,
|
||||
newValue => {
|
||||
if (!newValue) {
|
||||
notes.value = '';
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const title = computed(() => {
|
||||
return props.action === 'approve'
|
||||
? t('ASSIGNMENT_SETTINGS.LEAVES.APPROVE.MODAL_TITLE')
|
||||
: t('ASSIGNMENT_SETTINGS.LEAVES.REJECT.MODAL_TITLE');
|
||||
});
|
||||
|
||||
const message = computed(() => {
|
||||
if (!props.leave) return '';
|
||||
|
||||
const agentName = props.leave.agent?.name || '';
|
||||
const dates = `${new Date(props.leave.start_date).toLocaleDateString()} - ${new Date(props.leave.end_date).toLocaleDateString()}`;
|
||||
|
||||
return props.action === 'approve'
|
||||
? t('ASSIGNMENT_SETTINGS.LEAVES.APPROVE.MODAL_MESSAGE', {
|
||||
name: agentName,
|
||||
dates,
|
||||
})
|
||||
: t('ASSIGNMENT_SETTINGS.LEAVES.REJECT.MODAL_MESSAGE', {
|
||||
name: agentName,
|
||||
dates,
|
||||
});
|
||||
});
|
||||
|
||||
const confirmText = computed(() => {
|
||||
return props.action === 'approve'
|
||||
? t('ASSIGNMENT_SETTINGS.LEAVES.APPROVE.CONFIRM')
|
||||
: t('ASSIGNMENT_SETTINGS.LEAVES.REJECT.CONFIRM');
|
||||
});
|
||||
|
||||
const notesLabel = computed(() => {
|
||||
return props.action === 'approve'
|
||||
? 'Comments (Optional)'
|
||||
: 'Rejection Reason';
|
||||
});
|
||||
|
||||
const notesPlaceholder = computed(() => {
|
||||
return props.action === 'approve'
|
||||
? 'Add any comments for this approval...'
|
||||
: 'Please provide a reason for rejecting this leave request...';
|
||||
});
|
||||
|
||||
const canConfirm = computed(() => {
|
||||
return (
|
||||
props.action === 'approve' ||
|
||||
(props.action === 'reject' && notes.value.trim())
|
||||
);
|
||||
});
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!canConfirm.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
emit('confirm', notes.value.trim());
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:show', false);
|
||||
notes.value = '';
|
||||
loading.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :show="show" :on-close="handleClose" size="medium">
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center"
|
||||
:class="action === 'approve' ? 'bg-green-100' : 'bg-red-100'"
|
||||
>
|
||||
<i
|
||||
class="text-xl"
|
||||
:class="
|
||||
action === 'approve'
|
||||
? 'icon-checkmark text-green-600'
|
||||
: 'icon-dismiss text-red-600'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-semibold text-slate-900 mb-2">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<p class="text-slate-600 mb-6">
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
<!-- Leave Details Card -->
|
||||
<div
|
||||
v-if="leave"
|
||||
class="bg-slate-50 rounded-lg p-4 mb-6 border border-slate-200"
|
||||
>
|
||||
<h4 class="font-medium text-slate-900 mb-3">Leave Details</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-slate-700">Type:</span>
|
||||
<span class="ml-2 capitalize text-slate-900">{{
|
||||
leave.leave_type
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-slate-700">Duration:</span>
|
||||
<span class="ml-2 text-slate-900">{{ leave.total_days || 'N/A' }} days</span>
|
||||
</div>
|
||||
<div class="col-span-full">
|
||||
<span class="font-medium text-slate-700">Reason:</span>
|
||||
<p class="mt-1 text-slate-600 bg-white p-2 rounded border">
|
||||
{{ leave.reason }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes/Comments -->
|
||||
<div class="mb-6">
|
||||
<TextArea
|
||||
v-model="notes"
|
||||
:label="notesLabel"
|
||||
:placeholder="notesPlaceholder"
|
||||
rows="3"
|
||||
:required="action === 'reject'"
|
||||
/>
|
||||
<div
|
||||
v-if="action === 'reject' && !notes.trim()"
|
||||
class="text-xs text-red-500 mt-1"
|
||||
>
|
||||
Rejection reason is required
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button variant="ghost" :disabled="loading" @click="handleCancel">
|
||||
{{ t('COMMON.CANCEL') }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="action === 'approve' ? 'primary' : 'danger'"
|
||||
:is-loading="loading"
|
||||
:disabled="!canConfirm"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import ReportMetricCard from 'dashboard/routes/dashboard/settings/reports/components/ReportMetricCard.vue';
|
||||
import DateRange from 'dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue';
|
||||
import Agents from 'dashboard/routes/dashboard/settings/reports/components/Filters/Agents.vue';
|
||||
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Table from 'dashboard/components/table/Table.vue';
|
||||
import BarChart from 'shared/components/charts/BarChart.vue';
|
||||
// LineChart and PieChart will be replaced with BarChart
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const metrics = computed(() => getters['assignmentMetrics/getMetrics'].value);
|
||||
const agentHistory = computed(
|
||||
() => getters['assignmentMetrics/getAgentHistory'].value
|
||||
);
|
||||
const policyPerformance = computed(
|
||||
() => getters['assignmentMetrics/getPolicyPerformance'].value
|
||||
);
|
||||
const agentUtilization = computed(
|
||||
() => getters['assignmentMetrics/getAgentUtilization'].value
|
||||
);
|
||||
const uiFlags = computed(() => getters['assignmentMetrics/getUIFlags'].value);
|
||||
const assignmentPolicies = computed(
|
||||
() => getters['assignmentPolicies/getAssignmentPolicies'].value
|
||||
);
|
||||
|
||||
const filters = ref({
|
||||
startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0],
|
||||
endDate: new Date().toISOString().split('T')[0],
|
||||
agentIds: [],
|
||||
policyIds: [],
|
||||
groupBy: 'day',
|
||||
});
|
||||
|
||||
const exportType = ref('overview');
|
||||
const activeTab = ref('overview');
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview', label: t('ASSIGNMENT_SETTINGS.METRICS.TABS.OVERVIEW') },
|
||||
{
|
||||
key: 'agent_performance',
|
||||
label: t('ASSIGNMENT_SETTINGS.METRICS.TABS.AGENT_PERFORMANCE'),
|
||||
},
|
||||
{
|
||||
key: 'policy_performance',
|
||||
label: t('ASSIGNMENT_SETTINGS.METRICS.TABS.POLICY_PERFORMANCE'),
|
||||
},
|
||||
{
|
||||
key: 'utilization',
|
||||
label: t('ASSIGNMENT_SETTINGS.METRICS.TABS.UTILIZATION'),
|
||||
},
|
||||
];
|
||||
|
||||
const agentHistoryColumns = [
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.AGENT'),
|
||||
key: 'agent',
|
||||
width: '25%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.ASSIGNMENTS_HANDLED'),
|
||||
key: 'assignments_handled',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.AVG_RESPONSE_TIME'),
|
||||
key: 'avg_response_time',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.SATISFACTION_SCORE'),
|
||||
key: 'satisfaction_score',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.EFFICIENCY_SCORE'),
|
||||
key: 'efficiency_score',
|
||||
width: '15%',
|
||||
},
|
||||
];
|
||||
|
||||
const policyPerformanceColumns = [
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.POLICY'),
|
||||
key: 'policy',
|
||||
width: '30%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.TOTAL_ASSIGNMENTS'),
|
||||
key: 'total_assignments',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.SUCCESS_RATE'),
|
||||
key: 'success_rate',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.AVG_HANDLING_TIME'),
|
||||
key: 'avg_handling_time',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.REASSIGNMENT_RATE'),
|
||||
key: 'reassignment_rate',
|
||||
width: '10%',
|
||||
},
|
||||
];
|
||||
|
||||
const applyFilters = () => {
|
||||
store.dispatch('assignmentMetrics/setFilters', filters.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('assignmentPolicies/get');
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
watch(
|
||||
filters,
|
||||
() => {
|
||||
applyFilters();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const exportReport = async () => {
|
||||
try {
|
||||
await store.dispatch('assignmentMetrics/exportReport', exportType.value);
|
||||
} catch (error) {
|
||||
// Export failed
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = seconds => {
|
||||
if (!seconds) return '-';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
const getAssignmentTrendData = computed(() => {
|
||||
const trends = getters['assignmentMetrics/getAssignmentTrends'].value || [];
|
||||
return {
|
||||
labels: trends.map(trend => new Date(trend.date).toLocaleDateString()),
|
||||
datasets: [
|
||||
{
|
||||
label: t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.ASSIGNMENTS'),
|
||||
data: trends.map(trend => trend.total_assignments),
|
||||
borderColor: 'rgb(99, 102, 241)',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const getUtilizationChartData = computed(() => {
|
||||
const utilization = agentUtilization.value || [];
|
||||
return {
|
||||
labels: utilization.map(a => a.agent_name),
|
||||
datasets: [
|
||||
{
|
||||
label: t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.UTILIZATION'),
|
||||
data: utilization.map(a => a.utilization_percentage),
|
||||
backgroundColor: [
|
||||
'rgba(99, 102, 241, 0.8)',
|
||||
'rgba(34, 197, 94, 0.8)',
|
||||
'rgba(251, 146, 60, 0.8)',
|
||||
'rgba(239, 68, 68, 0.8)',
|
||||
'rgba(168, 85, 247, 0.8)',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const getPolicyDistributionData = computed(() => {
|
||||
const performance = policyPerformance.value || [];
|
||||
return {
|
||||
labels: performance.map(p => p.policy_name),
|
||||
datasets: [
|
||||
{
|
||||
data: performance.map(p => p.total_assignments),
|
||||
backgroundColor: [
|
||||
'rgba(99, 102, 241, 0.8)',
|
||||
'rgba(34, 197, 94, 0.8)',
|
||||
'rgba(251, 146, 60, 0.8)',
|
||||
'rgba(239, 68, 68, 0.8)',
|
||||
'rgba(168, 85, 247, 0.8)',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('ASSIGNMENT_SETTINGS.METRICS.HEADER')"
|
||||
:description="$t('ASSIGNMENT_SETTINGS.METRICS.SUBHEADER')"
|
||||
/>
|
||||
|
||||
<div class="p-8">
|
||||
<!-- Filters -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">
|
||||
{{ $t('ASSIGNMENT_SETTINGS.METRICS.FILTERS.TITLE') }}
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<DateRange
|
||||
v-model:start-date="filters.startDate"
|
||||
v-model:end-date="filters.endDate"
|
||||
/>
|
||||
|
||||
<Agents
|
||||
v-model="filters.agentIds"
|
||||
:label="$t('ASSIGNMENT_SETTINGS.METRICS.FILTERS.AGENTS')"
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
v-model="filters.policyIds"
|
||||
:options="assignmentPolicies"
|
||||
:label="$t('ASSIGNMENT_SETTINGS.METRICS.FILTERS.POLICIES')"
|
||||
:placeholder="
|
||||
$t('ASSIGNMENT_SETTINGS.METRICS.FILTERS.POLICIES_PLACEHOLDER')
|
||||
"
|
||||
track-by="id"
|
||||
label-key="name"
|
||||
/>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
icon="download"
|
||||
:loading="uiFlags.isExporting"
|
||||
@click="exportReport"
|
||||
>
|
||||
{{ $t('ASSIGNMENT_SETTINGS.METRICS.EXPORT') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="flex gap-4 border-b border-slate-200">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="[
|
||||
activeTab === tab.key
|
||||
? 'text-woot-600 border-b-2 border-woot-600'
|
||||
: 'text-slate-600 hover:text-slate-900',
|
||||
]"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="uiFlags.isFetching"
|
||||
class="flex items-center justify-center h-64"
|
||||
>
|
||||
<Spinner size="large" />
|
||||
</div>
|
||||
|
||||
<!-- Overview Tab -->
|
||||
<div v-else-if="activeTab === 'overview'" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<ReportMetricCard
|
||||
:title="$t('ASSIGNMENT_SETTINGS.METRICS.CARDS.TOTAL_ASSIGNMENTS')"
|
||||
:value="
|
||||
(metrics.overview && metrics.overview.total_assignments) || 0
|
||||
"
|
||||
icon="chat"
|
||||
/>
|
||||
|
||||
<ReportMetricCard
|
||||
:title="$t('ASSIGNMENT_SETTINGS.METRICS.CARDS.AVG_RESPONSE_TIME')"
|
||||
:value="
|
||||
formatTime(
|
||||
(metrics.overview && metrics.overview.avg_response_time) || 0
|
||||
)
|
||||
"
|
||||
icon="clock"
|
||||
/>
|
||||
|
||||
<ReportMetricCard
|
||||
:title="$t('ASSIGNMENT_SETTINGS.METRICS.CARDS.SATISFACTION_SCORE')"
|
||||
:value="`${(metrics.overview && metrics.overview.satisfaction_score) || 0}%`"
|
||||
icon="emoji-happy"
|
||||
/>
|
||||
|
||||
<ReportMetricCard
|
||||
:title="$t('ASSIGNMENT_SETTINGS.METRICS.CARDS.REASSIGNMENT_RATE')"
|
||||
:value="`${(metrics.overview && metrics.overview.reassignment_rate) || 0}%`"
|
||||
icon="refresh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
||||
<h3 class="text-lg font-medium mb-4">
|
||||
{{ $t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.ASSIGNMENT_TRENDS') }}
|
||||
</h3>
|
||||
<BarChart
|
||||
:data="getAssignmentTrendData"
|
||||
:chart-options="{ height: 300 }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Performance Tab -->
|
||||
<div v-else-if="activeTab === 'agent_performance'" class="space-y-6">
|
||||
<Table :columns="agentHistoryColumns" :data="agentHistory">
|
||||
<template #agent="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
:src="row.agent_avatar"
|
||||
:alt="row.agent_name"
|
||||
class="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span class="font-medium">{{ row.agent_name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #assignments_handled="{ row }">
|
||||
<span class="font-medium">{{ row.assignments_handled }}</span>
|
||||
</template>
|
||||
|
||||
<template #avg_response_time="{ row }">
|
||||
{{ formatTime(row.avg_response_time) }}
|
||||
</template>
|
||||
|
||||
<template #satisfaction_score="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ row.satisfaction_score }}</span>
|
||||
<div class="w-16 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-green-500"
|
||||
:style="{ width: `${row.satisfaction_score}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #efficiency_score="{ row }">
|
||||
<span
|
||||
class="font-medium"
|
||||
:class="[
|
||||
row.efficiency_score >= 80
|
||||
? 'text-green-600'
|
||||
: row.efficiency_score >= 60
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600',
|
||||
]"
|
||||
>
|
||||
{{ row.efficiency_score }}
|
||||
</span>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Policy Performance Tab -->
|
||||
<div v-else-if="activeTab === 'policy_performance'" class="space-y-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">
|
||||
{{ $t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.POLICY_DISTRIBUTION') }}
|
||||
</h3>
|
||||
<BarChart
|
||||
:data="getPolicyDistributionData"
|
||||
:chart-options="{ height: 300 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">
|
||||
{{
|
||||
$t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.POLICY_SUCCESS_RATES')
|
||||
}}
|
||||
</h3>
|
||||
<BarChart
|
||||
:data="{
|
||||
labels: (policyPerformance || []).map(p => p.policy_name),
|
||||
datasets: [
|
||||
{
|
||||
label: t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.SUCCESS_RATE'),
|
||||
data: (policyPerformance || []).map(p => p.success_rate),
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.8)',
|
||||
},
|
||||
],
|
||||
}"
|
||||
:height="300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table :columns="policyPerformanceColumns" :data="policyPerformance">
|
||||
<template #policy="{ row }">
|
||||
<span class="font-medium">{{ row.policy_name }}</span>
|
||||
</template>
|
||||
|
||||
<template #total_assignments="{ row }">
|
||||
{{ row.total_assignments }}
|
||||
</template>
|
||||
|
||||
<template #success_rate="{ row }">
|
||||
<span
|
||||
class="font-medium"
|
||||
:class="[
|
||||
row.success_rate >= 90
|
||||
? 'text-green-600'
|
||||
: row.success_rate >= 70
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600',
|
||||
]"
|
||||
>
|
||||
{{ row.success_rate }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #avg_handling_time="{ row }">
|
||||
{{ formatTime(row.avg_handling_time) }}
|
||||
</template>
|
||||
|
||||
<template #reassignment_rate="{ row }">
|
||||
{{ row.reassignment_rate }}
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Utilization Tab -->
|
||||
<div v-else-if="activeTab === 'utilization'" class="space-y-6">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
||||
<h3 class="text-lg font-medium mb-4">
|
||||
{{ $t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.AGENT_UTILIZATION') }}
|
||||
</h3>
|
||||
<BarChart
|
||||
:data="getUtilizationChartData"
|
||||
:height="400"
|
||||
:options="{
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components/widgets/forms/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isEditMode = computed(() => !!route.params.id);
|
||||
const policyId = computed(() => route.params.id);
|
||||
|
||||
const uiFlags = computed(() => getters['assignmentPolicies/getUIFlags'].value);
|
||||
|
||||
const loading = ref(false);
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
assignment_order: 'round_robin',
|
||||
conversation_priority: 'earliest_created',
|
||||
fair_distribution_limit: 10,
|
||||
fair_distribution_window: 3600,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const assignmentOrderOptions = [
|
||||
{
|
||||
value: 'round_robin',
|
||||
label: 'Round Robin',
|
||||
description: 'Distribute conversations evenly among available agents',
|
||||
},
|
||||
{
|
||||
value: 'balanced',
|
||||
label: 'Balanced Assignment (Enterprise)',
|
||||
description: 'Assign to agents with the most available capacity',
|
||||
},
|
||||
];
|
||||
|
||||
const conversationPriorityOptions = [
|
||||
{
|
||||
value: 'earliest_created',
|
||||
label: 'Earliest Created',
|
||||
description: 'Assign conversations based on creation time',
|
||||
},
|
||||
{
|
||||
value: 'longest_waiting',
|
||||
label: 'Longest Waiting',
|
||||
description: 'Assign conversations that have been waiting the longest',
|
||||
},
|
||||
];
|
||||
|
||||
const errors = ref({});
|
||||
|
||||
const loadPolicy = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const policy = await store.dispatch(
|
||||
'assignmentPolicies/show',
|
||||
policyId.value
|
||||
);
|
||||
formData.value = {
|
||||
name: policy.name,
|
||||
description: policy.description || '',
|
||||
assignment_order: policy.assignment_order || 'round_robin',
|
||||
conversation_priority: policy.conversation_priority || 'earliest_created',
|
||||
fair_distribution_limit: policy.fair_distribution_limit || 10,
|
||||
fair_distribution_window: policy.fair_distribution_window || 3600,
|
||||
enabled: policy.enabled !== false,
|
||||
};
|
||||
} catch (error) {
|
||||
useAlert(t('ASSIGNMENT_SETTINGS.POLICIES.LOAD.ERROR'));
|
||||
router.push({ name: 'assignment_policies_list' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
errors.value = {};
|
||||
|
||||
if (!formData.value.name.trim()) {
|
||||
errors.value.name = t('ASSIGNMENT_SETTINGS.POLICIES.FORM.NAME_REQUIRED');
|
||||
}
|
||||
|
||||
if (!formData.value.assignment_order) {
|
||||
errors.value.assignment_order = 'Assignment order is required';
|
||||
}
|
||||
|
||||
if (!formData.value.conversation_priority) {
|
||||
errors.value.conversation_priority = 'Conversation priority is required';
|
||||
}
|
||||
|
||||
if (formData.value.fair_distribution_limit < 1) {
|
||||
errors.value.fair_distribution_limit =
|
||||
'Fair distribution limit must be at least 1';
|
||||
}
|
||||
|
||||
if (formData.value.fair_distribution_window < 60) {
|
||||
errors.value.fair_distribution_window =
|
||||
'Fair distribution window must be at least 60 seconds';
|
||||
}
|
||||
|
||||
return Object.keys(errors.value).length === 0;
|
||||
};
|
||||
|
||||
const savePolicy = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: formData.value.name.trim(),
|
||||
description: formData.value.description.trim(),
|
||||
assignment_order: formData.value.assignment_order,
|
||||
conversation_priority: formData.value.conversation_priority,
|
||||
fair_distribution_limit: formData.value.fair_distribution_limit,
|
||||
fair_distribution_window: formData.value.fair_distribution_window,
|
||||
enabled: formData.value.enabled,
|
||||
};
|
||||
|
||||
if (isEditMode.value) {
|
||||
await store.dispatch('assignmentPolicies/update', {
|
||||
id: policyId.value,
|
||||
...payload,
|
||||
});
|
||||
useAlert(t('ASSIGNMENT_SETTINGS.POLICIES.UPDATE.SUCCESS'));
|
||||
} else {
|
||||
await store.dispatch('assignmentPolicies/create', payload);
|
||||
useAlert(t('ASSIGNMENT_SETTINGS.POLICIES.CREATE.SUCCESS'));
|
||||
}
|
||||
|
||||
router.push({ name: 'assignment_policies_list' });
|
||||
} catch (error) {
|
||||
const message = isEditMode.value
|
||||
? t('ASSIGNMENT_SETTINGS.POLICIES.UPDATE.ERROR')
|
||||
: t('ASSIGNMENT_SETTINGS.POLICIES.CREATE.ERROR');
|
||||
useAlert(message);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
router.push({ name: 'assignment_policies_list' });
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEditMode.value) {
|
||||
await loadPolicy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
:title="
|
||||
isEditMode
|
||||
? $t('ASSIGNMENT_SETTINGS.POLICIES.EDIT_HEADER')
|
||||
: $t('ASSIGNMENT_SETTINGS.POLICIES.NEW_HEADER')
|
||||
"
|
||||
:description="$t('ASSIGNMENT_SETTINGS.POLICIES.FORM_DESCRIPTION')"
|
||||
:back-button-label="$t('ASSIGNMENT_SETTINGS.POLICIES.BACK_BUTTON')"
|
||||
@back="cancel"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center h-64">
|
||||
<Spinner :size="48" />
|
||||
</div>
|
||||
|
||||
<div v-else class="max-w-2xl p-8">
|
||||
<form @submit.prevent="savePolicy">
|
||||
<div class="space-y-6">
|
||||
<!-- Basic Information -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Basic Information</h3>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
v-model="formData.name"
|
||||
:label="$t('ASSIGNMENT_SETTINGS.POLICIES.FORM.NAME')"
|
||||
:placeholder="
|
||||
$t('ASSIGNMENT_SETTINGS.POLICIES.FORM.NAME_PLACEHOLDER')
|
||||
"
|
||||
:error="errors.name"
|
||||
required
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="formData.description"
|
||||
:label="$t('ASSIGNMENT_SETTINGS.POLICIES.FORM.DESCRIPTION')"
|
||||
:placeholder="
|
||||
$t(
|
||||
'ASSIGNMENT_SETTINGS.POLICIES.FORM.DESCRIPTION_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
rows="3"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch v-model="formData.enabled" />
|
||||
<label class="text-sm font-medium"> Policy Enabled </label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Order -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Assignment Order</h3>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="option in assignmentOrderOptions"
|
||||
:key="option.value"
|
||||
class="p-4 border rounded-lg cursor-pointer transition-colors"
|
||||
:class="[
|
||||
formData.assignment_order === option.value
|
||||
? 'border-woot-500 bg-woot-50'
|
||||
: 'border-slate-200 hover:border-slate-300',
|
||||
]"
|
||||
@click="formData.assignment_order = option.value"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="formData.assignment_order === option.value"
|
||||
class="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<h4 class="font-medium">{{ option.label }}</h4>
|
||||
<p class="text-sm text-slate-600 mt-1">
|
||||
{{ option.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="errors.assignment_order"
|
||||
class="text-red-500 text-sm mt-2"
|
||||
>
|
||||
{{ errors.assignment_order }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversation Priority -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Conversation Priority</h3>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="option in conversationPriorityOptions"
|
||||
:key="option.value"
|
||||
class="p-4 border rounded-lg cursor-pointer transition-colors"
|
||||
:class="[
|
||||
formData.conversation_priority === option.value
|
||||
? 'border-woot-500 bg-woot-50'
|
||||
: 'border-slate-200 hover:border-slate-300',
|
||||
]"
|
||||
@click="formData.conversation_priority = option.value"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="formData.conversation_priority === option.value"
|
||||
class="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<h4 class="font-medium">{{ option.label }}</h4>
|
||||
<p class="text-sm text-slate-600 mt-1">
|
||||
{{ option.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="errors.conversation_priority"
|
||||
class="text-red-500 text-sm mt-2"
|
||||
>
|
||||
{{ errors.conversation_priority }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fair Distribution Settings -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Fair Distribution Settings</h3>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
v-model.number="formData.fair_distribution_limit"
|
||||
type="number"
|
||||
label="Fair Distribution Limit"
|
||||
placeholder="10"
|
||||
help-text="Maximum conversations to assign to an agent within the time window"
|
||||
:error="errors.fair_distribution_limit"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model.number="formData.fair_distribution_window"
|
||||
type="number"
|
||||
label="Fair Distribution Window (seconds)"
|
||||
placeholder="3600"
|
||||
help-text="Time window in seconds for fair distribution (default: 1 hour = 3600 seconds)"
|
||||
:error="errors.fair_distribution_window"
|
||||
min="60"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 mt-8">
|
||||
<Button variant="ghost" @click="cancel">
|
||||
{{ $t('COMMON.CANCEL') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
|
||||
>
|
||||
{{ isEditMode ? $t('COMMON.UPDATE') : $t('COMMON.CREATE') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, h } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
useVueTable,
|
||||
createColumnHelper,
|
||||
getCoreRowModel,
|
||||
} from '@tanstack/vue-table';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Table from 'dashboard/components/table/Table.vue';
|
||||
import ConfirmationModal from 'dashboard/components/widgets/modal/ConfirmationModal.vue';
|
||||
import Modal from 'dashboard/components/Modal.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||
import inboxesAPI from 'dashboard/api/inboxes';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const policies = computed(
|
||||
() => getters['assignmentPolicies/getPoliciesByPriority'].value
|
||||
);
|
||||
const uiFlags = computed(() => getters['assignmentPolicies/getUIFlags'].value);
|
||||
|
||||
const loading = ref({});
|
||||
const showDeletePopup = ref(false);
|
||||
const showAssignInboxModal = ref(false);
|
||||
const selectedPolicy = ref(null);
|
||||
const selectedInboxId = ref(null);
|
||||
const inboxes = computed(() => getters['inboxes/getInboxes'].value);
|
||||
|
||||
// Filter out already assigned inboxes
|
||||
const availableInboxes = computed(() => {
|
||||
if (!selectedPolicy.value) return inboxes.value;
|
||||
|
||||
const assignedInboxIds = selectedPolicy.value.inboxes?.map(i => i.id) || [];
|
||||
return inboxes.value.filter(inbox => !assignedInboxIds.includes(inbox.id));
|
||||
});
|
||||
|
||||
// Column helper for TanStack Table
|
||||
const columnHelper = createColumnHelper();
|
||||
const columns = [
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Policy Name',
|
||||
cell: info =>
|
||||
h('div', { class: 'font-medium text-slate-900' }, info.getValue()),
|
||||
size: 250,
|
||||
}),
|
||||
columnHelper.accessor('description', {
|
||||
header: 'Description',
|
||||
cell: info =>
|
||||
h('div', { class: 'text-sm text-slate-600' }, info.getValue() || '-'),
|
||||
size: 350,
|
||||
}),
|
||||
columnHelper.accessor('assignment_order', {
|
||||
header: 'Assignment Order',
|
||||
cell: info =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'font-medium text-slate-900' },
|
||||
info.getValue() === 'round_robin' ? 'Round Robin' : 'Balanced'
|
||||
),
|
||||
size: 150,
|
||||
}),
|
||||
columnHelper.accessor('conversation_priority', {
|
||||
header: 'Conversation Priority',
|
||||
cell: info =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'text-sm text-slate-600' },
|
||||
info.getValue() === 'earliest_created'
|
||||
? 'Earliest Created'
|
||||
: 'Longest Waiting'
|
||||
),
|
||||
size: 150,
|
||||
}),
|
||||
columnHelper.accessor('enabled', {
|
||||
header: 'Status',
|
||||
cell: info =>
|
||||
h('div', { class: 'flex items-center gap-2' }, [
|
||||
h('div', {
|
||||
class: `w-2 h-2 rounded-full ${
|
||||
info.getValue() ? 'bg-green-500' : 'bg-slate-400'
|
||||
}`,
|
||||
}),
|
||||
h(
|
||||
'span',
|
||||
{ class: 'text-sm' },
|
||||
info.getValue() ? 'Active' : 'Inactive'
|
||||
),
|
||||
]),
|
||||
size: 120,
|
||||
}),
|
||||
columnHelper.accessor(row => row.inboxes || [], {
|
||||
id: 'inboxes',
|
||||
header: 'Assigned Inboxes',
|
||||
cell: info => {
|
||||
const inboxes = info.getValue();
|
||||
const policyId = info.row.original.id;
|
||||
|
||||
if (!inboxes || inboxes.length === 0) {
|
||||
return h(
|
||||
'span',
|
||||
{ class: 'text-sm text-slate-500' },
|
||||
'No inboxes assigned'
|
||||
);
|
||||
}
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex flex-wrap gap-1' },
|
||||
inboxes.map(inbox =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class:
|
||||
'inline-flex items-center gap-1 px-2 py-1 text-xs bg-woot-50 text-woot-700 rounded-full border border-woot-200 hover:bg-woot-100 transition-colors group',
|
||||
},
|
||||
[
|
||||
h('span', inbox.name),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class:
|
||||
'ml-1 -mr-1 p-0.5 rounded-full hover:bg-woot-200 transition-colors',
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
unassignInbox(policyId, inbox.id, inbox.name);
|
||||
},
|
||||
title: `Remove ${inbox.name}`,
|
||||
},
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
class: 'w-3 h-3',
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20',
|
||||
},
|
||||
h('path', {
|
||||
'fill-rule': 'evenodd',
|
||||
d: 'M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z',
|
||||
'clip-rule': 'evenodd',
|
||||
})
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
size: 250,
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: info =>
|
||||
h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
icon: 'edit',
|
||||
onClick: () => editPolicy(info.row.original),
|
||||
},
|
||||
() => 'Edit'
|
||||
),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
icon: 'mail-inbox-all',
|
||||
onClick: () => openAssignInboxModal(info.row.original),
|
||||
title: 'Assign policy to inbox',
|
||||
},
|
||||
() => 'Assign'
|
||||
),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
color: 'ruby',
|
||||
size: 'sm',
|
||||
icon: 'delete',
|
||||
isLoading: loading.value[info.row.original.id],
|
||||
onClick: () => openDeletePopup(info.row.original),
|
||||
},
|
||||
() => 'Delete'
|
||||
),
|
||||
]),
|
||||
size: 200,
|
||||
}),
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('assignmentPolicies/get');
|
||||
store.dispatch('inboxes/get');
|
||||
});
|
||||
|
||||
const navigateToNew = () => {
|
||||
router.push({ name: 'assignment_policies_new' });
|
||||
};
|
||||
|
||||
const editPolicy = policy => {
|
||||
router.push({
|
||||
name: 'assignment_policies_edit',
|
||||
params: { id: policy.id },
|
||||
});
|
||||
};
|
||||
|
||||
const openDeletePopup = policy => {
|
||||
selectedPolicy.value = policy;
|
||||
showDeletePopup.value = true;
|
||||
};
|
||||
|
||||
const closeDeletePopup = () => {
|
||||
selectedPolicy.value = null;
|
||||
showDeletePopup.value = false;
|
||||
};
|
||||
|
||||
const openAssignInboxModal = policy => {
|
||||
selectedPolicy.value = policy;
|
||||
selectedInboxId.value = null;
|
||||
showAssignInboxModal.value = true;
|
||||
};
|
||||
|
||||
const closeAssignInboxModal = () => {
|
||||
selectedPolicy.value = null;
|
||||
selectedInboxId.value = null;
|
||||
showAssignInboxModal.value = false;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedPolicy.value) return;
|
||||
|
||||
try {
|
||||
loading.value[selectedPolicy.value.id] = true;
|
||||
await store.dispatch('assignmentPolicies/delete', selectedPolicy.value.id);
|
||||
useAlert('Assignment policy deleted successfully');
|
||||
closeDeletePopup();
|
||||
} catch (error) {
|
||||
useAlert('Failed to delete assignment policy');
|
||||
} finally {
|
||||
loading.value[selectedPolicy.value.id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const assignPolicyToInbox = async () => {
|
||||
if (!selectedPolicy.value || !selectedInboxId.value) return;
|
||||
|
||||
try {
|
||||
loading.value[selectedPolicy.value.id] = true;
|
||||
|
||||
// Use the inboxes API service
|
||||
await inboxesAPI.assignPolicy(
|
||||
selectedInboxId.value,
|
||||
selectedPolicy.value.id
|
||||
);
|
||||
|
||||
useAlert('Policy assigned to inbox successfully');
|
||||
// Refresh the policies to show the updated inbox assignments
|
||||
await store.dispatch('assignmentPolicies/get');
|
||||
closeAssignInboxModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to assign policy:', error);
|
||||
useAlert(error.response?.data?.error || 'Failed to assign policy to inbox');
|
||||
} finally {
|
||||
loading.value[selectedPolicy.value.id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const unassignInbox = async (policyId, inboxId, inboxName) => {
|
||||
try {
|
||||
loading.value[policyId] = true;
|
||||
|
||||
// Call the API to remove the assignment
|
||||
await inboxesAPI.removePolicy(inboxId);
|
||||
|
||||
useAlert(`Policy unassigned from ${inboxName}`);
|
||||
// Refresh the policies to show the updated inbox assignments
|
||||
await store.dispatch('assignmentPolicies/get');
|
||||
} catch (error) {
|
||||
console.error('Failed to unassign policy:', error);
|
||||
useAlert(
|
||||
error.response?.data?.error || 'Failed to unassign policy from inbox'
|
||||
);
|
||||
} finally {
|
||||
loading.value[policyId] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Table instance
|
||||
const table = computed(() =>
|
||||
useVueTable({
|
||||
data: policies.value,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
title="Assignment Policies"
|
||||
description="Create and manage policies to automatically assign conversations to agents based on various criteria"
|
||||
back-button-label="Back to Assignment"
|
||||
@back="$router.push({ name: 'assignment_index' })"
|
||||
>
|
||||
<template #actions>
|
||||
<Button variant="solid" icon="add" @click="navigateToNew">
|
||||
New Policy
|
||||
</Button>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
|
||||
<div class="p-8">
|
||||
<div
|
||||
v-if="uiFlags.isFetching"
|
||||
class="flex items-center justify-center h-64"
|
||||
>
|
||||
<Spinner :size="48" />
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="!policies.length"
|
||||
title="No Assignment Policies"
|
||||
message="Create your first assignment policy to automatically distribute conversations to your agents"
|
||||
>
|
||||
<Button variant="solid" icon="add" @click="navigateToNew">
|
||||
New Policy
|
||||
</Button>
|
||||
</EmptyState>
|
||||
|
||||
<div v-else>
|
||||
<Table :table="table" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
v-model:show="showDeletePopup"
|
||||
title="Delete Assignment Policy"
|
||||
:message="`Are you sure you want to delete the policy '${selectedPolicy?.name}'? This action cannot be undone.`"
|
||||
confirm-text="Delete"
|
||||
cancel-text="Cancel"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="closeDeletePopup"
|
||||
/>
|
||||
|
||||
<!-- Assign Inbox Modal -->
|
||||
<Modal
|
||||
v-model:show="showAssignInboxModal"
|
||||
:on-close="closeAssignInboxModal"
|
||||
>
|
||||
<div class="flex flex-col p-6 max-w-md">
|
||||
<h2 class="text-lg font-semibold mb-3">Assign Policy to Inbox</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div
|
||||
class="px-3 py-1 bg-slate-100 rounded-full text-sm font-medium text-slate-700"
|
||||
>
|
||||
{{ selectedPolicy?.name }}
|
||||
</div>
|
||||
<span class="text-sm text-slate-500">→</span>
|
||||
<span class="text-sm text-slate-600">Select inbox</span>
|
||||
</div>
|
||||
|
||||
<select
|
||||
v-model="selectedInboxId"
|
||||
class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-woot-500 focus:border-woot-500 text-sm"
|
||||
>
|
||||
<option value="">Choose an inbox...</option>
|
||||
<option
|
||||
v-for="inbox in availableInboxes"
|
||||
:key="inbox.id"
|
||||
:value="inbox.id"
|
||||
>
|
||||
{{ inbox.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<p
|
||||
v-if="availableInboxes.length === 0"
|
||||
class="mt-2 text-xs text-slate-500"
|
||||
>
|
||||
All inboxes are already assigned to this policy
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" @click="closeAssignInboxModal">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
:is-loading="loading[selectedPolicy?.id]"
|
||||
:disabled="!selectedInboxId || availableInboxes.length === 0"
|
||||
@click="assignPolicyToInbox"
|
||||
>
|
||||
Assign Inbox
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,6 +32,8 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['back']);
|
||||
|
||||
const helpURL = getHelpUrlForFeature(props.featureName);
|
||||
|
||||
const openInNewTab = url => {
|
||||
@@ -46,6 +48,7 @@ const openInNewTab = url => {
|
||||
v-if="backButtonLabel"
|
||||
compact
|
||||
:button-label="backButtonLabel"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -298,8 +298,8 @@ export default {
|
||||
this.inbox.allow_messages_after_resolved;
|
||||
this.continuityViaEmail = this.inbox.continuity_via_email;
|
||||
this.channelWebsiteUrl = this.inbox.website_url;
|
||||
this.channelWelcomeTitle = this.inbox.welcome_title;
|
||||
this.channelWelcomeTagline = this.inbox.welcome_tagline;
|
||||
this.channelWelcomeTitle = this.inbox.welcome_title || '';
|
||||
this.channelWelcomeTagline = this.inbox.welcome_tagline || '';
|
||||
this.selectedFeatureFlags = this.inbox.selected_feature_flags || [];
|
||||
this.replyTime = this.inbox.reply_time;
|
||||
this.locktoSingleConversation = this.inbox.lock_to_single_conversation;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import account from './account/account.routes';
|
||||
import agent from './agents/agent.routes';
|
||||
import agentBot from './agentBots/agentBot.routes';
|
||||
import assignment from './assignment/assignment.routes';
|
||||
import attributes from './attributes/attributes.routes';
|
||||
import automation from './automation/automation.routes';
|
||||
import auditlogs from './auditlogs/audit.routes';
|
||||
@@ -45,6 +46,7 @@ export default {
|
||||
...account.routes,
|
||||
...agent.routes,
|
||||
...agentBot.routes,
|
||||
...assignment.routes,
|
||||
...attributes.routes,
|
||||
...automation.routes,
|
||||
...auditlogs.routes,
|
||||
|
||||
Reference in New Issue
Block a user