add assignment v2 UI

This commit is contained in:
Tanmay Deep Sharma
2025-07-31 14:58:10 +05:30
parent 2c204326e5
commit 8f9c45ea8c
46 changed files with 4844 additions and 42 deletions
@@ -0,0 +1,66 @@
/* global axios */
import ApiClient from './ApiClient';
export class AgentCapacityPoliciesAPI extends ApiClient {
constructor() {
super('agent_capacity_policies', { accountScoped: true });
}
// GET /api/v1/accounts/:accountId/agent_capacity_policies
get(params = {}) {
return axios.get(this.url, { params });
}
// GET /api/v1/accounts/:accountId/agent_capacity_policies/:id
show(id) {
return axios.get(`${this.url}/${id}`);
}
// POST /api/v1/accounts/:accountId/agent_capacity_policies
create(data) {
return axios.post(this.url, { agent_capacity_policy: data });
}
// PUT /api/v1/accounts/:accountId/agent_capacity_policies/:id
update(id, data) {
return axios.put(`${this.url}/${id}`, { agent_capacity_policy: data });
}
// DELETE /api/v1/accounts/:accountId/agent_capacity_policies/:id
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
// POST /api/v1/accounts/:accountId/agent_capacity_policies/:id/users
assignUser(id, userId) {
return axios.post(`${this.url}/${id}/users`, {
user_id: userId,
});
}
// DELETE /api/v1/accounts/:accountId/agent_capacity_policies/:id/users/:userId
removeUser(id, userId) {
return axios.delete(`${this.url}/${id}/users/${userId}`);
}
// POST /api/v1/accounts/:accountId/agent_capacity_policies/:id/inbox_limits/:inboxId
setInboxLimit(id, inboxId, conversationLimit) {
return axios.post(`${this.url}/${id}/inbox_limits/${inboxId}`, {
conversation_limit: conversationLimit,
});
}
// DELETE /api/v1/accounts/:accountId/agent_capacity_policies/:id/inbox_limits/:inboxId
removeInboxLimit(id, inboxId) {
return axios.delete(`${this.url}/${id}/inbox_limits/${inboxId}`);
}
// GET /api/v1/accounts/:accountId/agents/:agentId/capacity
getAgentCapacity(accountId, agentId, inboxId = null) {
const url = `/api/v1/accounts/${accountId}/agents/${agentId}/capacity`;
const params = inboxId ? { inbox_id: inboxId } : {};
return axios.get(url, { params });
}
}
export default new AgentCapacityPoliciesAPI();
@@ -0,0 +1,52 @@
/* global axios */
import ApiClient from './ApiClient';
export class AssignmentMetricsAPI extends ApiClient {
constructor() {
super('reports/assignment_metrics', { accountScoped: true });
}
// GET /api/v1/accounts/:accountId/reports/assignment_metrics
get(params = {}) {
return axios.get(this.url, { params });
}
// GET /api/v1/accounts/:accountId/agents/:userId/assignment_history
getAgentHistory(userId, params = {}) {
return axios.get(
`/api/v1/accounts/${this.accountId}/agents/${userId}/assignment_history`,
{ params }
);
}
// Get all agents assignment history
getAllAgentsHistory(params = {}) {
return axios.get(this.url, {
params: { ...params, include_agent_history: true },
});
}
// Get policy performance metrics
getPolicyPerformance(params = {}) {
return axios.get(this.url, {
params: { ...params, include_policy_performance: true },
});
}
// Get agent utilization metrics
getAgentUtilization(params = {}) {
return axios.get(this.url, {
params: { ...params, include_utilization: true },
});
}
// Export report data
exportReport(type, params = {}) {
return axios.get(`${this.url}/export`, {
params: { type, ...params },
responseType: 'blob',
});
}
}
export default new AssignmentMetricsAPI();
@@ -0,0 +1,35 @@
/* global axios */
import ApiClient from './ApiClient';
export class AssignmentPoliciesAPI extends ApiClient {
constructor() {
super('assignment_policies', { accountScoped: true });
}
// GET /api/v1/accounts/:accountId/assignment_policies
get() {
return axios.get(this.url);
}
// GET /api/v1/accounts/:accountId/assignment_policies/:id
show(id) {
return axios.get(`${this.url}/${id}`);
}
// POST /api/v1/accounts/:accountId/assignment_policies
create(data) {
return axios.post(this.url, { assignment_policy: data });
}
// PUT /api/v1/accounts/:accountId/assignment_policies/:id
update(id, data) {
return axios.put(`${this.url}/${id}`, { assignment_policy: data });
}
// DELETE /api/v1/accounts/:accountId/assignment_policies/:id
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
}
export default new AssignmentPoliciesAPI();
+10
View File
@@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient {
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
assignPolicy(inboxId, policyId) {
return axios.post(`${this.url}/${inboxId}/assignment_policy`, {
assignment_policy_id: policyId,
});
}
removePolicy(inboxId) {
return axios.delete(`${this.url}/${inboxId}/assignment_policy`);
}
}
export default new Inboxes();
+55
View File
@@ -0,0 +1,55 @@
/* global axios */
import ApiClient from './ApiClient';
export class LeavesAPI extends ApiClient {
constructor() {
super('leaves', { accountScoped: true });
}
// GET /api/v1/accounts/:accountId/leaves
get(params = {}) {
return axios.get(this.url, { params });
}
// GET /api/v1/accounts/:accountId/leaves/:id
show(id) {
return axios.get(`${this.url}/${id}`);
}
// POST /api/v1/accounts/:accountId/leaves
create(data) {
return axios.post(this.url, { leave: data.leave, user_id: data.user_id });
}
// PUT /api/v1/accounts/:accountId/leaves/:id
update(id, data) {
return axios.put(`${this.url}/${id}`, { leave: data });
}
// DELETE /api/v1/accounts/:accountId/leaves/:id
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
// POST /api/v1/accounts/:accountId/leaves/:id/approve
approve(id, data = {}) {
return axios.post(`${this.url}/${id}/approve`, data);
}
// POST /api/v1/accounts/:accountId/leaves/:id/reject
reject(id, data = {}) {
return axios.post(`${this.url}/${id}/reject`, data);
}
// GET /api/v1/accounts/:accountId/leaves/my_leaves
getMyLeaves(params = {}) {
return axios.get(`${this.url}/my_leaves`, { params });
}
// GET /api/v1/accounts/:accountId/leaves/pending_approvals
getPendingApprovals(params = {}) {
return axios.get(`${this.url}/pending_approvals`, { params });
}
}
export default new LeavesAPI();
@@ -54,6 +54,11 @@ const filteredAttrs = computed(() => {
standardAttrs[key] = value;
});
// Default to type="button" to prevent accidental form submissions
if (!standardAttrs.type) {
standardAttrs.type = 'button';
}
return standardAttrs;
});
@@ -91,6 +91,7 @@ const toggleOption = option => {
<template #trigger="{ toggle }">
<button
v-if="hasItems"
type="button"
class="bg-n-alpha-2 py-2 rounded-lg h-8 flex items-center px-0"
@click="toggle"
>
@@ -450,6 +450,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-message-square-quote',
to: accountScopedRoute('canned_list'),
},
{
name: 'Settings Assignment',
label: t('SIDEBAR.ASSIGNMENT'),
icon: 'i-lucide-users-round',
to: accountScopedRoute('assignment_settings'),
},
{
name: 'Settings Integrations',
label: t('SIDEBAR.INTEGRATIONS'),
@@ -8,6 +8,10 @@ export default {
NextButton,
},
props: {
show: {
type: Boolean,
default: false,
},
title: {
type: String,
default: 'This is a title',
@@ -25,35 +29,49 @@ export default {
default: 'No',
},
},
emits: ['update:show', 'confirm', 'cancel'],
data: () => ({
show: false,
localShow: false,
resolvePromise: undefined,
rejectPromise: undefined,
}),
watch: {
show(val) {
this.localShow = val;
},
},
methods: {
showConfirmation() {
this.show = true;
this.localShow = true;
this.$emit('update:show', true);
return new Promise((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
});
},
confirm() {
this.resolvePromise(true);
this.show = false;
if (this.resolvePromise) {
this.resolvePromise(true);
}
this.$emit('confirm');
this.localShow = false;
this.$emit('update:show', false);
},
cancel() {
this.resolvePromise(false);
this.show = false;
if (this.resolvePromise) {
this.resolvePromise(false);
}
this.$emit('cancel');
this.localShow = false;
this.$emit('update:show', false);
},
},
};
</script>
<template>
<Modal v-model:show="show" :on-close="cancel">
<Modal v-model:show="localShow" :on-close="cancel">
<div class="h-auto overflow-auto flex flex-col">
<woot-modal-header :header-title="title" :header-content="description" />
<div class="flex flex-row justify-end gap-2 py-4 px-6 w-full">
@@ -0,0 +1,325 @@
{
"ASSIGNMENT_SETTINGS": {
"HEADER": "Assignment Settings",
"DESCRIPTION": "Manage conversation assignment policies, agent leave requests, capacity planning, and view assignment metrics.",
"POLICIES": {
"TITLE": "Assignment Policies",
"DESCRIPTION": "Configure how conversations are assigned to agents",
"HEADER": "Assignment Policies",
"SUBHEADER": "Create and manage policies to automatically assign conversations to agents based on various criteria.",
"NEW_BUTTON": "New Policy",
"BACK_BUTTON": "Back to Policies",
"NEW_HEADER": "Create Assignment Policy",
"EDIT_HEADER": "Edit Assignment Policy",
"FORM_DESCRIPTION": "Configure the policy settings for automatic conversation assignment.",
"EMPTY": {
"TITLE": "No assignment policies found",
"MESSAGE": "Create your first assignment policy to start automatically assigning conversations to agents."
},
"TABLE": {
"NAME": "Policy Name",
"DESCRIPTION": "Description",
"PRIORITY": "Priority",
"STATUS": "Status",
"ACTIONS": "Actions"
},
"FORM": {
"BASIC_INFO": "Basic Information",
"NAME": "Policy Name",
"NAME_PLACEHOLDER": "Enter policy name",
"NAME_REQUIRED": "Policy name is required",
"DESCRIPTION": "Description",
"DESCRIPTION_PLACEHOLDER": "Describe what this policy does",
"ACTIVE": "Active",
"POLICY_TYPE": "Policy Type",
"TYPE_REQUIRED": "Policy type is required",
"CONDITIONS": "Conditions",
"INBOXES": "Apply to Inboxes",
"INBOXES_PLACEHOLDER": "Select inboxes where this policy applies",
"TEAMS": "Apply to Teams",
"TEAMS_PLACEHOLDER": "Select teams to include",
"AGENTS": "Specific Agents",
"AGENTS_PLACEHOLDER": "Select specific agents (leave empty for all)",
"BUSINESS_HOURS_ONLY": "Apply only during business hours",
"EXCLUDED_AGENTS": "Excluded Agents",
"EXCLUDED_AGENTS_PLACEHOLDER": "Select agents to exclude from assignments",
"ADVANCED": "Advanced Settings",
"WEIGHT": "Policy Weight",
"WEIGHT_PLACEHOLDER": "100",
"WEIGHT_HELP": "Higher weight policies get more assignments (1-100)",
"MAX_ASSIGNMENTS": "Max Assignments per Agent",
"MAX_ASSIGNMENTS_PLACEHOLDER": "No limit",
"MAX_ASSIGNMENTS_HELP": "Maximum concurrent conversations per agent",
"REASSIGNMENT_INTERVAL": "Reassignment Interval (minutes)",
"REASSIGNMENT_INTERVAL_PLACEHOLDER": "0",
"REASSIGNMENT_INTERVAL_HELP": "Time before unresponded conversations are reassigned"
},
"TYPES": {
"ROUND_ROBIN": "Round Robin",
"ROUND_ROBIN_DESC": "Distribute conversations evenly among available agents",
"LOAD_BALANCED": "Load Balanced",
"LOAD_BALANCED_DESC": "Assign to agents with the least active conversations",
"SKILL_BASED": "Skill Based",
"SKILL_BASED_DESC": "Match conversations to agents based on required skills",
"CUSTOM": "Custom",
"CUSTOM_DESC": "Use custom logic for assignment"
},
"DELETE": {
"TITLE": "Delete Policy",
"MESSAGE": "Are you sure you want to delete the policy '{name}'? This action cannot be undone.",
"SUCCESS": "Policy deleted successfully",
"ERROR": "Failed to delete policy"
},
"CREATE": {
"SUCCESS": "Policy created successfully",
"ERROR": "Failed to create policy"
},
"UPDATE": {
"SUCCESS": "Policy updated successfully",
"ERROR": "Failed to update policy"
},
"LOAD": {
"ERROR": "Failed to load policy"
},
"PRIORITY": {
"ERROR": "Failed to update priority"
}
},
"PAYWALL": {
"TITLE": "Premium Feature",
"UPGRADE_NOW": "Upgrade Now",
"CANCEL_ANYTIME": "Cancel anytime"
},
"ENTERPRISE_PAYWALL": {
"ASK_ADMIN": "Please contact your administrator to upgrade."
},
"LEAVES": {
"TITLE": "Leave Management",
"DESCRIPTION": "Manage agent leave requests and approvals",
"HEADER": "Leave Management",
"SUBHEADER": "View and manage leave requests for agents. Approved leaves will exclude agents from automatic assignments.",
"NEW_BUTTON": "Request Leave",
"BACK_BUTTON": "Back to Leaves",
"NEW_HEADER": "Request Leave",
"NEW_DESCRIPTION": "Submit a leave request for approval.",
"DETAILS_HEADER": "Leave Details",
"DETAILS_DESCRIPTION": "View complete information about this leave request.",
"EMPTY": {
"TITLE": "No leave requests found",
"MESSAGE": "There are no leave requests matching your filters."
},
"TABS": {
"ALL": "All Leaves",
"MY_LEAVES": "My Leaves",
"PENDING_APPROVALS": "Pending Approvals"
},
"FILTER": {
"STATUS": "Status"
},
"TABLE": {
"AGENT": "Agent",
"TYPE": "Leave Type",
"DATES": "Dates",
"REASON": "Reason",
"STATUS": "Status",
"ACTIONS": "Actions"
},
"STATUS": {
"PENDING": "Pending",
"APPROVED": "Approved",
"REJECTED": "Rejected"
},
"TYPES": {
"ANNUAL": "Annual Leave",
"SICK": "Sick Leave",
"PERSONAL": "Personal Leave",
"MATERNITY": "Maternity Leave",
"PATERNITY": "Paternity Leave",
"UNPAID": "Unpaid Leave",
"OTHER": "Other"
},
"FORM": {
"TYPE": "Leave Type",
"TYPE_PLACEHOLDER": "Select leave type",
"TYPE_REQUIRED": "Leave type is required",
"START_DATE": "Start Date",
"START_DATE_PLACEHOLDER": "Select start date",
"START_DATE_REQUIRED": "Start date is required",
"END_DATE": "End Date",
"END_DATE_PLACEHOLDER": "Select end date",
"END_DATE_REQUIRED": "End date is required",
"END_DATE_INVALID": "End date must be after start date",
"IS_HALF_DAY": "Half day leave",
"HALF_DAY_PERIOD": "Half Day Period",
"HALF_DAY_INVALID": "Half day leave must be for a single date",
"TOTAL_DAYS": "Total Days",
"REASON": "Reason",
"REASON_PLACEHOLDER": "Provide reason for leave",
"REASON_REQUIRED": "Reason is required",
"SUBMIT": "Submit Request"
},
"HALF_DAY": {
"MORNING": "Morning",
"AFTERNOON": "Afternoon"
},
"DETAILS": {
"TYPE": "Leave Type",
"DATES": "Leave Period",
"REASON": "Reason",
"REQUESTED_ON": "Requested On",
"APPROVED_BY": "Approved By",
"REJECTED_BY": "Rejected By",
"APPROVER_NOTES": "Approver Notes"
},
"APPROVAL_NOTES": "Notes (Optional)",
"APPROVAL_NOTES_PLACEHOLDER": "Add any notes for this decision",
"DELETE": {
"TITLE": "Delete Leave Request",
"MESSAGE": "Are you sure you want to delete this leave request?",
"CONFIRM": "Are you sure you want to delete this leave request?",
"SUCCESS": "Leave request deleted successfully",
"ERROR": "Failed to delete leave request"
},
"CREATE": {
"SUCCESS": "Leave request submitted successfully",
"ERROR": "Failed to submit leave request"
},
"LOAD": {
"ERROR": "Failed to load leave details"
},
"APPROVE": {
"BUTTON": "Approve",
"MODAL_TITLE": "Approve Leave Request",
"MODAL_MESSAGE": "Approve leave request for {name} from {dates}?",
"CONFIRM": "Approve Leave",
"SUCCESS": "Leave request approved successfully",
"ERROR": "Failed to approve leave request"
},
"REJECT": {
"BUTTON": "Reject",
"MODAL_TITLE": "Reject Leave Request",
"MODAL_MESSAGE": "Reject leave request for {name} from {dates}?",
"CONFIRM": "Reject Leave",
"SUCCESS": "Leave request rejected successfully",
"ERROR": "Failed to reject leave request"
}
},
"CAPACITY": {
"TITLE": "Agent Capacity",
"DESCRIPTION": "Set maximum conversation limits for agents (Enterprise)",
"HEADER": "Agent Capacity Management",
"SUBHEADER": "Configure maximum conversation capacity for agents to prevent overload and ensure quality support.",
"NEW_BUTTON": "New Capacity Policy",
"BACK_BUTTON": "Back to Capacity",
"NEW_HEADER": "Create Capacity Policy",
"EDIT_HEADER": "Edit Capacity Policy",
"FORM_DESCRIPTION": "Set the maximum number of concurrent conversations agents can handle.",
"ENTERPRISE_ONLY": "Agent capacity management is available in the Enterprise edition.",
"EMPTY": {
"TITLE": "No capacity policies found",
"MESSAGE": "Create capacity policies to limit the number of concurrent conversations assigned to agents."
},
"AGENTS_EMPTY": {
"TITLE": "No agent capacities configured",
"MESSAGE": "Create capacity policies and assign agents to manage their workload."
},
"TABS": {
"POLICIES": "Capacity Policies",
"AGENTS": "Agent Capacities"
},
"TABLE": {
"NAME": "Policy Name",
"DESCRIPTION": "Description",
"MAX_CAPACITY": "Max Capacity",
"INBOX_LIMITS": "Inbox Limits",
"AGENTS_COUNT": "Agents",
"ACTIONS": "Actions",
"AGENT": "Agent",
"POLICY": "Policy",
"CURRENT_LOAD": "Current Load",
"UTILIZATION": "Utilization"
},
"FORM": {
"NAME": "Policy Name",
"NAME_PLACEHOLDER": "e.g., Standard Agent Capacity",
"NAME_REQUIRED": "Policy name is required",
"DESCRIPTION": "Description",
"DESCRIPTION_PLACEHOLDER": "Describe this capacity policy",
"MAX_CAPACITY": "Maximum Capacity",
"MAX_CAPACITY_PLACEHOLDER": "10",
"MAX_CAPACITY_HELP": "Maximum number of concurrent conversations per agent",
"CAPACITY_REQUIRED": "Capacity must be at least 1",
"AGENTS": "Assign Agents",
"AGENTS_PLACEHOLDER": "Select agents for this policy",
"AGENTS_REQUIRED": "Please select at least one agent",
"NOTE_TITLE": "Note",
"NOTE_MESSAGE": "Agents can only be assigned to one capacity policy at a time. Assigning an agent to this policy will remove them from any other capacity policy."
},
"DELETE": {
"TITLE": "Delete Capacity Policy",
"MESSAGE": "Are you sure you want to delete the policy '{name}'? This will remove capacity limits for all assigned agents.",
"SUCCESS": "Capacity policy deleted successfully",
"ERROR": "Failed to delete capacity policy"
},
"CREATE": {
"SUCCESS": "Capacity policy created successfully",
"ERROR": "Failed to create capacity policy"
},
"UPDATE": {
"SUCCESS": "Capacity policy updated successfully",
"ERROR": "Failed to update capacity policy"
},
"LOAD": {
"ERROR": "Failed to load capacity policy"
},
"AVAILABLE_ON": "Agent capacity management is available on <b>Enterprise</b> plans.",
"UPGRADE_PROMPT": "Upgrade to Enterprise to set conversation limits for your agents."
},
"METRICS": {
"TITLE": "Assignment Metrics",
"DESCRIPTION": "View detailed analytics and performance metrics",
"HEADER": "Assignment Metrics",
"SUBHEADER": "Monitor assignment performance, agent utilization, and policy effectiveness.",
"EXPORT": "Export Report",
"FILTERS": {
"TITLE": "Filters",
"AGENTS": "Agents",
"POLICIES": "Policies",
"POLICIES_PLACEHOLDER": "All policies"
},
"TABS": {
"OVERVIEW": "Overview",
"AGENT_PERFORMANCE": "Agent Performance",
"POLICY_PERFORMANCE": "Policy Performance",
"UTILIZATION": "Utilization"
},
"CARDS": {
"TOTAL_ASSIGNMENTS": "Total Assignments",
"AVG_RESPONSE_TIME": "Avg Response Time",
"SATISFACTION_SCORE": "Satisfaction Score",
"REASSIGNMENT_RATE": "Reassignment Rate"
},
"CHARTS": {
"ASSIGNMENT_TRENDS": "Assignment Trends",
"ASSIGNMENTS": "Assignments",
"UTILIZATION": "Utilization %",
"POLICY_DISTRIBUTION": "Assignments by Policy",
"POLICY_SUCCESS_RATES": "Policy Success Rates",
"SUCCESS_RATE": "Success Rate %",
"AGENT_UTILIZATION": "Agent Utilization"
},
"TABLE": {
"AGENT": "Agent",
"ASSIGNMENTS_HANDLED": "Assignments",
"AVG_RESPONSE_TIME": "Avg Response",
"SATISFACTION_SCORE": "Satisfaction",
"EFFICIENCY_SCORE": "Efficiency",
"POLICY": "Policy",
"TOTAL_ASSIGNMENTS": "Total",
"SUCCESS_RATE": "Success Rate",
"AVG_HANDLING_TIME": "Avg Handling",
"REASSIGNMENT_RATE": "Reassignments"
}
}
}
}
@@ -246,6 +246,18 @@
},
"COMMON": {
"OR": "Or",
"CLICK_HERE": "click here"
"CLICK_HERE": "click here",
"DELETE": "Delete",
"CANCEL": "Cancel",
"EDIT": "Edit",
"UPDATE": "Update",
"CREATE": "Create",
"SAVE": "Save",
"AGENTS": "Agents",
"VIEW": "View",
"ACTIVE": "Active",
"INACTIVE": "Inactive",
"ALL": "All",
"DAYS": "Days"
}
}
@@ -1,6 +1,7 @@
import advancedFilters from './advancedFilters.json';
import agentBots from './agentBots.json';
import agentMgmt from './agentMgmt.json';
import assignmentSettings from './assignmentSettings.json';
import attributesMgmt from './attributesMgmt.json';
import auditLogs from './auditLogs.json';
import automation from './automation.json';
@@ -40,6 +41,7 @@ export default {
...advancedFilters,
...agentBots,
...agentMgmt,
...assignmentSettings,
...attributesMgmt,
...auditLogs,
...automation,
@@ -308,6 +308,7 @@
"MACROS": "Macros",
"TEAMS": "Teams",
"BILLING": "Billing",
"ASSIGNMENT": "Assignment",
"CUSTOM_VIEWS_FOLDER": "Folders",
"CUSTOM_VIEWS_SEGMENTS": "Segments",
"ALL_CONTACTS": "All Contacts",
@@ -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,
},
},
],
},
],
};
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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,
+8
View File
@@ -2,8 +2,11 @@ import { createStore } from 'vuex';
import accounts from './modules/accounts';
import agentBots from './modules/agentBots';
import agentCapacity from './modules/agentCapacity';
import agents from './modules/agents';
import articles from './modules/helpCenterArticles';
import assignmentMetrics from './modules/assignmentMetrics';
import assignmentPolicies from './modules/assignmentPolicies';
import attributes from './modules/attributes';
import auditlogs from './modules/auditlogs';
import auth from './modules/auth';
@@ -35,6 +38,7 @@ import inboxes from './modules/inboxes';
import inboxMembers from './modules/inboxMembers';
import integrations from './modules/integrations';
import labels from './modules/labels';
import leaves from './modules/leaves';
import macros from './modules/macros';
import notifications from './modules/notifications';
import portals from './modules/helpCenterPortals';
@@ -60,8 +64,11 @@ export default createStore({
modules: {
accounts,
agentBots,
agentCapacity,
agents,
articles,
assignmentMetrics,
assignmentPolicies,
attributes,
auditlogs,
auth,
@@ -93,6 +100,7 @@ export default createStore({
inboxMembers,
integrations,
labels,
leaves,
macros,
notifications,
portals,
@@ -0,0 +1,139 @@
import agentCapacityPoliciesAPI from '../../../api/agentCapacityPolicies';
export const actions = {
get: async ({ commit }, params = {}) => {
commit('setUIFlag', { isFetching: true });
try {
const response = await agentCapacityPoliciesAPI.get(params);
const policies = response.data.agent_capacity_policies || response.data;
commit('setCapacityPolicies', policies);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isFetching: false });
}
},
show: async ({ commit }, id) => {
try {
const response = await agentCapacityPoliciesAPI.show(id);
const policy = response.data.agent_capacity_policy || response.data;
commit('setCapacityPolicy', policy);
return policy;
} catch (error) {
throw new Error(error);
}
},
create: async ({ commit }, data) => {
commit('setUIFlag', { isCreating: true });
try {
const response = await agentCapacityPoliciesAPI.create(data);
const policy = response.data.agent_capacity_policy || response.data;
commit('setCapacityPolicy', policy);
return policy;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isCreating: false });
}
},
update: async ({ commit }, { id, ...data }) => {
commit('setUIFlag', { isUpdating: true });
try {
const response = await agentCapacityPoliciesAPI.update(id, data);
commit('setCapacityPolicy', response.data);
return response.data;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isUpdating: false });
}
},
delete: async ({ commit }, id) => {
commit('setUIFlag', { isDeleting: true });
try {
await agentCapacityPoliciesAPI.delete(id);
commit('deleteCapacityPolicy', id);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isDeleting: false });
}
},
assignUser: async ({ commit }, { id, userId }) => {
commit('setUIFlag', { isAssigningAgents: true });
try {
const response = await agentCapacityPoliciesAPI.assignUser(id, userId);
return response.data;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isAssigningAgents: false });
}
},
removeUser: async ({ commit }, { id, userId }) => {
commit('setUIFlag', { isAssigningAgents: true });
try {
const response = await agentCapacityPoliciesAPI.removeUser(id, userId);
return response.data;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isAssigningAgents: false });
}
},
setInboxLimit: async ({ commit }, { id, inboxId, conversationLimit }) => {
commit('setUIFlag', { isUpdating: true });
try {
const response = await agentCapacityPoliciesAPI.setInboxLimit(
id,
inboxId,
conversationLimit
);
return response.data;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isUpdating: false });
}
},
removeInboxLimit: async ({ commit }, { id, inboxId }) => {
commit('setUIFlag', { isUpdating: true });
try {
const response = await agentCapacityPoliciesAPI.removeInboxLimit(
id,
inboxId
);
return response.data;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isUpdating: false });
}
},
getAgentCapacity: async ({ commit, rootGetters }, { agentId, inboxId }) => {
commit('setUIFlag', { isFetching: true });
try {
const accountId = rootGetters['auth/getCurrentAccountId'];
const response = await agentCapacityPoliciesAPI.getAgentCapacity(
accountId,
agentId,
inboxId
);
commit('setAgentCapacity', { agentId, data: response.data });
return response.data;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isFetching: false });
}
},
};
@@ -0,0 +1,30 @@
export const getters = {
getCapacityPolicies: state => Object.values(state.policies),
getCapacityPolicy: state => id => state.policies[id],
getAgentCapacities: state => Object.values(state.agentCapacities),
getAgentCapacity: state => agentId => state.agentCapacities[agentId],
getUIFlags: state => state.uiFlags,
getActivePolicies: state => {
return Object.values(state.policies).filter(policy => policy.active);
},
getAgentsByPolicy: state => policyId => {
return Object.values(state.agentCapacities).filter(
capacity => capacity.agent_capacity_policy_id === policyId
);
},
getAvailableCapacity: state => agentId => {
const capacity = state.agentCapacities[agentId];
if (!capacity) return 0;
return capacity.max_capacity - capacity.current_load;
},
getAgentsAtCapacity: state => {
return Object.values(state.agentCapacities).filter(
capacity => capacity.current_load >= capacity.max_capacity
);
},
};
@@ -0,0 +1,23 @@
import { getters } from './getters';
import { actions } from './actions';
import { mutations } from './mutations';
const state = {
policies: {},
agentCapacities: {},
uiFlags: {
isFetching: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
isAssigningAgents: false,
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};
@@ -0,0 +1,60 @@
export const mutations = {
setUIFlag(state, data) {
state.uiFlags = {
...state.uiFlags,
...data,
};
},
setCapacityPolicies(state, response) {
// Handle both array and object with agent_capacity_policies key
const policies = response.agent_capacity_policies || response;
state.policies = {};
if (Array.isArray(policies)) {
policies.forEach(policy => {
state.policies[policy.id] = policy;
});
}
},
setCapacityPolicy(state, response) {
// Handle both direct policy and object with agent_capacity_policy key
const policy = response.agent_capacity_policy || response;
state.policies = {
...state.policies,
[policy.id]: policy,
};
},
deleteCapacityPolicy(state, id) {
const { [id]: deleted, ...rest } = state.policies;
state.policies = rest;
},
setAgentCapacities(state, capacities) {
if (Array.isArray(capacities)) {
const newCapacities = {};
capacities.forEach(capacity => {
newCapacities[capacity.agent_id] = capacity;
});
state.agentCapacities = {
...state.agentCapacities,
...newCapacities,
};
} else {
state.agentCapacities = capacities;
}
},
setAgentCapacity(state, { agentId, data }) {
state.agentCapacities = {
...state.agentCapacities,
[agentId]: data,
};
},
deleteAgentCapacity(state, agentId) {
const { [agentId]: deleted, ...rest } = state.agentCapacities;
state.agentCapacities = rest;
},
};
@@ -0,0 +1,137 @@
import assignmentMetricsAPI from '../../../api/assignmentMetrics';
export const actions = {
getMetrics: async ({ commit, state }) => {
commit('setUIFlag', { isFetching: true });
try {
const response = await assignmentMetricsAPI.get(state.filters);
commit('setOverviewMetrics', response.data.overview || {});
commit('setAssignmentTrends', response.data.trends || []);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isFetching: false });
}
},
getAgentHistory: async ({ commit, state }) => {
commit('setUIFlag', { isFetchingHistory: true });
try {
const response = await assignmentMetricsAPI.getAllAgentsHistory(
state.filters
);
commit('setAgentHistory', response.data.agent_history || []);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isFetchingHistory: false });
}
},
getAgentHistoryById: async ({ commit, state }, userId) => {
commit('setUIFlag', { isFetchingHistory: true });
try {
const response = await assignmentMetricsAPI.getAgentHistory(
userId,
state.filters
);
return response.data;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isFetchingHistory: false });
}
},
getPolicyPerformance: async ({ commit, state }) => {
commit('setUIFlag', { isFetchingPerformance: true });
try {
const response = await assignmentMetricsAPI.getPolicyPerformance(
state.filters
);
commit('setPolicyPerformance', response.data.policy_performance || []);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isFetchingPerformance: false });
}
},
getAgentUtilization: async ({ commit, state }) => {
commit('setUIFlag', { isFetchingUtilization: true });
try {
const response = await assignmentMetricsAPI.getAgentUtilization(
state.filters
);
commit('setAgentUtilization', response.data.agent_utilization || []);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isFetchingUtilization: false });
}
},
getAssignmentDistribution: async ({ commit, state }) => {
commit('setUIFlag', { isFetchingDistribution: true });
try {
const response = await assignmentMetricsAPI.get(state.filters);
commit(
'setAssignmentDistribution',
response.data.assignment_distribution || {}
);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isFetchingDistribution: false });
}
},
exportReport: async ({ commit, state }, type) => {
commit('setUIFlag', { isExporting: true });
try {
const response = await assignmentMetricsAPI.exportReport(
type,
state.filters
);
// Create download link
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute(
'download',
`assignment-${type}-report-${Date.now()}.csv`
);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
return true;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isExporting: false });
}
},
setFilters: ({ commit, dispatch }, filters) => {
commit('setFilters', filters);
// Refresh all data with new filters
dispatch('getMetrics');
dispatch('getAgentHistory');
dispatch('getPolicyPerformance');
dispatch('getAgentUtilization');
dispatch('getAssignmentDistribution');
},
refreshAllMetrics: ({ dispatch }) => {
return Promise.all([
dispatch('getMetrics'),
dispatch('getAgentHistory'),
dispatch('getPolicyPerformance'),
dispatch('getAgentUtilization'),
dispatch('getAssignmentDistribution'),
]);
},
};
@@ -0,0 +1,62 @@
export const getters = {
getMetrics: state => state.metrics,
getOverviewMetrics: state => state.metrics.overview,
getAgentHistory: state => state.metrics.agentHistory,
getPolicyPerformance: state => state.metrics.policyPerformance,
getAgentUtilization: state => state.metrics.agentUtilization,
getAssignmentDistribution: state => state.metrics.assignmentDistribution,
getAssignmentTrends: state => state.metrics.assignmentTrends,
getFilters: state => state.filters,
getUIFlags: state => state.uiFlags,
getTopPerformingAgents: state => {
return [...state.metrics.agentHistory]
.sort((a, b) => b.assignments_handled - a.assignments_handled)
.slice(0, 5);
},
getTopPerformingPolicies: state => {
return [...state.metrics.policyPerformance]
.sort((a, b) => b.success_rate - a.success_rate)
.slice(0, 5);
},
getAverageUtilization: state => {
const utilization = state.metrics.agentUtilization;
if (!utilization.length) return 0;
const totalUtilization = utilization.reduce(
(sum, agent) => sum + agent.utilization_percentage,
0
);
return (totalUtilization / utilization.length).toFixed(2);
},
getCalculatedTrends: state => {
const history = state.metrics.agentHistory;
if (!history.length) return [];
// Group by date and calculate trends
const trendMap = {};
history.forEach(entry => {
const date = entry.date;
if (!trendMap[date]) {
trendMap[date] = {
date,
total_assignments: 0,
avg_response_time: 0,
count: 0,
};
}
trendMap[date].total_assignments += entry.assignments_handled;
trendMap[date].avg_response_time += entry.avg_response_time;
trendMap[date].count += 1;
});
return Object.values(trendMap).map(trend => ({
date: trend.date,
total_assignments: trend.total_assignments,
avg_response_time: trend.avg_response_time / trend.count,
}));
},
};
@@ -0,0 +1,37 @@
import { getters } from './getters';
import { actions } from './actions';
import { mutations } from './mutations';
const state = {
metrics: {
overview: {},
agentHistory: [],
policyPerformance: [],
agentUtilization: [],
assignmentDistribution: {},
assignmentTrends: [],
},
filters: {
startDate: null,
endDate: null,
agentIds: [],
policyIds: [],
groupBy: 'day',
},
uiFlags: {
isFetching: false,
isFetchingHistory: false,
isFetchingPerformance: false,
isFetchingUtilization: false,
isFetchingDistribution: false,
isExporting: false,
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};
@@ -0,0 +1,39 @@
export const mutations = {
setUIFlag(state, data) {
state.uiFlags = {
...state.uiFlags,
...data,
};
},
setFilters(state, filters) {
state.filters = {
...state.filters,
...filters,
};
},
setOverviewMetrics(state, metrics) {
state.metrics.overview = metrics;
},
setAgentHistory(state, history) {
state.metrics.agentHistory = history;
},
setPolicyPerformance(state, performance) {
state.metrics.policyPerformance = performance;
},
setAgentUtilization(state, utilization) {
state.metrics.agentUtilization = utilization;
},
setAssignmentDistribution(state, distribution) {
state.metrics.assignmentDistribution = distribution;
},
setAssignmentTrends(state, trends) {
state.metrics.assignmentTrends = trends;
},
};
@@ -0,0 +1,67 @@
import assignmentPoliciesAPI from '../../../api/assignmentPolicies';
export const actions = {
get: async ({ commit }) => {
commit('setUIFlag', { isFetching: true });
try {
const response = await assignmentPoliciesAPI.get();
const policies = response.data.assignment_policies || response.data;
commit('setAssignmentPolicies', policies);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isFetching: false });
}
},
show: async ({ commit }, id) => {
try {
const response = await assignmentPoliciesAPI.show(id);
const policy = response.data.assignment_policy || response.data;
commit('setAssignmentPolicy', policy);
return policy;
} catch (error) {
throw new Error(error);
}
},
create: async ({ commit }, data) => {
commit('setUIFlag', { isCreating: true });
try {
const response = await assignmentPoliciesAPI.create(data);
const policy = response.data.assignment_policy || response.data;
commit('setAssignmentPolicy', policy);
return policy;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isCreating: false });
}
},
update: async ({ commit }, { id, ...data }) => {
commit('setUIFlag', { isUpdating: true });
try {
const response = await assignmentPoliciesAPI.update(id, data);
const policy = response.data.assignment_policy || response.data;
commit('setAssignmentPolicy', policy);
return policy;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isUpdating: false });
}
},
delete: async ({ commit }, id) => {
commit('setUIFlag', { isDeleting: true });
try {
await assignmentPoliciesAPI.delete(id);
commit('deleteAssignmentPolicy', id);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isDeleting: false });
}
},
};
@@ -0,0 +1,11 @@
export const getters = {
getAssignmentPolicies: state => Object.values(state.records),
getAssignmentPolicy: state => id => state.records[id],
getUIFlags: state => state.uiFlags,
getActiveAssignmentPolicies: state => {
return Object.values(state.records).filter(policy => policy.enabled);
},
getPoliciesByPriority: state => {
return Object.values(state.records).sort((a, b) => a.id - b.id);
},
};
@@ -0,0 +1,21 @@
import { getters } from './getters';
import { actions } from './actions';
import { mutations } from './mutations';
const state = {
records: {},
uiFlags: {
isFetching: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};
@@ -0,0 +1,27 @@
export const mutations = {
setUIFlag(state, data) {
state.uiFlags = {
...state.uiFlags,
...data,
};
},
setAssignmentPolicies(state, policies) {
state.records = {};
policies.forEach(policy => {
state.records[policy.id] = policy;
});
},
setAssignmentPolicy(state, policy) {
state.records = {
...state.records,
[policy.id]: policy,
};
},
deleteAssignmentPolicy(state, id) {
const { [id]: deleted, ...rest } = state.records;
state.records = rest;
},
};
@@ -0,0 +1,125 @@
import leavesAPI from '../../../api/leaves';
export const actions = {
get: async ({ commit }, params = {}) => {
commit('setUIFlag', { isFetching: true });
try {
const response = await leavesAPI.get(params);
// Handle both possible response structures
const leaves = response.data.leaves || response.data || [];
const meta = response.data.meta || {};
commit('setLeaves', leaves);
commit('setMeta', meta);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isFetching: false });
}
},
show: async ({ commit }, id) => {
try {
const response = await leavesAPI.show(id);
const leave = response.data.leave || response.data;
commit('setLeave', leave);
return leave;
} catch (error) {
throw new Error(error);
}
},
create: async ({ commit }, data) => {
commit('setUIFlag', { isCreating: true });
try {
const response = await leavesAPI.create(data);
const leave = response.data.leave || response.data;
commit('setLeave', leave);
return leave;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isCreating: false });
}
},
update: async ({ commit }, { id, ...data }) => {
commit('setUIFlag', { isUpdating: true });
try {
const response = await leavesAPI.update(id, data);
commit('setLeave', response.data);
return response.data;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isUpdating: false });
}
},
delete: async ({ commit }, id) => {
commit('setUIFlag', { isDeleting: true });
try {
await leavesAPI.delete(id);
commit('deleteLeave', id);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isDeleting: false });
}
},
approve: async ({ commit }, { id, approverNotes }) => {
commit('setUIFlag', { isApproving: true });
try {
const response = await leavesAPI.approve(id, {
comments: approverNotes,
});
commit('setLeave', response.data.leave);
return response.data.leave;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isApproving: false });
}
},
reject: async ({ commit }, { id, approverNotes }) => {
commit('setUIFlag', { isRejecting: true });
try {
const response = await leavesAPI.reject(id, {
reason: approverNotes,
});
commit('setLeave', response.data.leave);
return response.data.leave;
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isRejecting: false });
}
},
getMyLeaves: async ({ commit }, params = {}) => {
commit('setUIFlag', { isFetching: true });
try {
const response = await leavesAPI.getMyLeaves(params);
commit('setLeaves', response.data.leaves);
commit('setMeta', response.data.meta);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isFetching: false });
}
},
getPendingApprovals: async ({ commit }, params = {}) => {
commit('setUIFlag', { isFetching: true });
try {
const response = await leavesAPI.getPendingApprovals(params);
commit('setLeaves', response.data.leaves);
commit('setMeta', response.data.meta);
} catch (error) {
throw new Error(error);
} finally {
commit('setUIFlag', { isFetching: false });
}
},
};
@@ -0,0 +1,41 @@
export const getters = {
getLeaves: state => Object.values(state.records),
getLeave: state => id => state.records[id],
getUIFlags: state => state.uiFlags,
getMeta: state => state.meta,
getPendingLeaves: state => {
return Object.values(state.records).filter(
leave => leave.status === 'pending'
);
},
getApprovedLeaves: state => {
return Object.values(state.records).filter(
leave => leave.status === 'approved'
);
},
getRejectedLeaves: state => {
return Object.values(state.records).filter(
leave => leave.status === 'rejected'
);
},
getLeavesByAgent: state => agentId => {
return Object.values(state.records).filter(
leave => leave.agent_id === agentId
);
},
getLeavesByDateRange: state => (startDate, endDate) => {
return Object.values(state.records).filter(leave => {
const leaveStart = new Date(leave.start_date);
const leaveEnd = new Date(leave.end_date);
const rangeStart = new Date(startDate);
const rangeEnd = new Date(endDate);
return leaveStart <= rangeEnd && leaveEnd >= rangeStart;
});
},
};
@@ -0,0 +1,27 @@
import { getters } from './getters';
import { actions } from './actions';
import { mutations } from './mutations';
const state = {
records: {},
meta: {
totalCount: 0,
currentPage: 1,
},
uiFlags: {
isFetching: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
isApproving: false,
isRejecting: false,
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};
@@ -0,0 +1,31 @@
export const mutations = {
setUIFlag(state, data) {
state.uiFlags = {
...state.uiFlags,
...data,
};
},
setMeta(state, meta) {
state.meta = meta;
},
setLeaves(state, leaves) {
state.records = {};
leaves.forEach(leave => {
state.records[leave.id] = leave;
});
},
setLeave(state, leave) {
state.records = {
...state.records,
[leave.id]: leave,
};
},
deleteLeave(state, id) {
const { [id]: deleted, ...rest } = state.records;
state.records = rest;
},
};