Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f6263f3c | ||
|
|
5f03418ac9 | ||
|
|
11370316b2 | ||
|
|
9a09df4563 | ||
|
|
364dbdd69f | ||
|
|
f1832148fd | ||
|
|
8f9c45ea8c | ||
|
|
24c7171a4e | ||
|
|
b5f3daafc9 | ||
|
|
2c204326e5 | ||
|
|
a33ae58452 | ||
|
|
a96e75b904 | ||
|
|
5bb88192c4 |
@@ -0,0 +1,176 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_enterprise_account
|
||||
before_action :fetch_agent_capacity_policy, only: [:show, :update, :destroy, :set_inbox_limit, :remove_inbox_limit, :assign_user, :remove_user]
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@agent_capacity_policies = Enterprise::AgentCapacityPolicy.where(account_id: Current.account.id).includes(:users, :inbox_capacity_limits)
|
||||
render json: { agent_capacity_policies: serialize_agent_capacity_policies(@agent_capacity_policies) }
|
||||
end
|
||||
|
||||
def show
|
||||
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }
|
||||
end
|
||||
|
||||
def create
|
||||
@agent_capacity_policy = Enterprise::AgentCapacityPolicy.new(agent_capacity_policy_params)
|
||||
@agent_capacity_policy.account_id = Current.account.id
|
||||
|
||||
if @agent_capacity_policy.save
|
||||
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }, status: :created
|
||||
else
|
||||
render json: { errors: @agent_capacity_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @agent_capacity_policy.update(agent_capacity_policy_params)
|
||||
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }
|
||||
else
|
||||
render json: { errors: @agent_capacity_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @agent_capacity_policy.destroy
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: @agent_capacity_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Set inbox capacity limit for a policy
|
||||
def set_inbox_limit
|
||||
inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find_or_initialize_by(inbox: inbox)
|
||||
|
||||
if inbox_limit.update(conversation_limit: params[:conversation_limit])
|
||||
render json: {
|
||||
inbox_capacity_limit: serialize_inbox_capacity_limit(inbox_limit)
|
||||
}
|
||||
else
|
||||
render json: { errors: inbox_limit.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Remove inbox capacity limit from a policy
|
||||
def remove_inbox_limit
|
||||
inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find_by(inbox: inbox)
|
||||
|
||||
if inbox_limit
|
||||
if inbox_limit.destroy
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: inbox_limit.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
render json: { error: 'Inbox limit not found' }, status: :not_found
|
||||
end
|
||||
end
|
||||
|
||||
# Assign a user to a capacity policy
|
||||
def assign_user
|
||||
user = Current.account.users.find(params[:user_id])
|
||||
account_user = Current.account.account_users.find_by!(user: user)
|
||||
|
||||
# Update the account_user to assign to this policy
|
||||
if account_user.update(agent_capacity_policy_id: @agent_capacity_policy.id)
|
||||
render json: {
|
||||
message: 'User assigned successfully',
|
||||
user_id: user.id,
|
||||
agent_capacity_policy_id: @agent_capacity_policy.id
|
||||
}
|
||||
else
|
||||
render json: { errors: account_user.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Remove a user from a capacity policy
|
||||
def remove_user
|
||||
user = Current.account.users.find(params[:user_id])
|
||||
account_user = Current.account.account_users.find_by!(user: user)
|
||||
|
||||
if account_user.agent_capacity_policy_id == @agent_capacity_policy.id
|
||||
if account_user.update(agent_capacity_policy_id: nil)
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: account_user.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
render json: { error: 'User not assigned to this policy' }, status: :not_found
|
||||
end
|
||||
end
|
||||
|
||||
# Get current capacity status for an agent
|
||||
def agent_capacity
|
||||
user = Current.account.users.find(params[:agent_id])
|
||||
inbox = params[:inbox_id] ? Current.account.inboxes.find(params[:inbox_id]) : nil
|
||||
|
||||
capacity_service = Enterprise::AssignmentV2::CapacityService.new
|
||||
capacity_data = if inbox
|
||||
capacity_service.get_agent_capacity(user, inbox)
|
||||
else
|
||||
capacity_service.get_agent_overall_capacity(user)
|
||||
end
|
||||
|
||||
render json: { agent_capacity: capacity_data }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_enterprise_account
|
||||
return if Current.account.feature_enabled?(:enterprise_agent_capacity)
|
||||
|
||||
render json: {
|
||||
error: 'Agent capacity policies are only available for enterprise accounts'
|
||||
}, status: :forbidden
|
||||
end
|
||||
|
||||
def fetch_agent_capacity_policy
|
||||
@agent_capacity_policy = Enterprise::AgentCapacityPolicy
|
||||
.where(account_id: Current.account.id)
|
||||
.includes(:users, inbox_capacity_limits: :inbox)
|
||||
.find(params[:id])
|
||||
end
|
||||
|
||||
def agent_capacity_policy_params
|
||||
params.require(:agent_capacity_policy).permit(:name, :description, exclusion_rules: {})
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(Enterprise::AgentCapacityPolicy) if defined?(Enterprise::AgentCapacityPolicy)
|
||||
end
|
||||
|
||||
def serialize_agent_capacity_policy(policy)
|
||||
{
|
||||
id: policy.id,
|
||||
name: policy.name,
|
||||
description: policy.description,
|
||||
exclusion_rules: policy.exclusion_rules,
|
||||
user_count: policy.users.count,
|
||||
inbox_limit_count: policy.inbox_capacity_limits.count,
|
||||
inbox_limits: policy.inbox_capacity_limits.map { |limit| serialize_inbox_capacity_limit(limit) },
|
||||
users: policy.users.map { |user| { id: user.id, name: user.name, email: user.email, avatar_url: user.avatar_url } },
|
||||
created_at: policy.created_at,
|
||||
updated_at: policy.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_agent_capacity_policies(policies)
|
||||
policies.map { |policy| serialize_agent_capacity_policy(policy) }
|
||||
end
|
||||
|
||||
def serialize_inbox_capacity_limit(limit)
|
||||
{
|
||||
id: limit.id,
|
||||
inbox_id: limit.inbox_id,
|
||||
inbox_name: limit.inbox.name,
|
||||
conversation_limit: limit.conversation_limit,
|
||||
created_at: limit.created_at,
|
||||
updated_at: limit.updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@assignment_policies = Current.account.assignment_policies.includes(:inboxes)
|
||||
render json: { assignment_policies: serialize_assignment_policies(@assignment_policies) }
|
||||
end
|
||||
|
||||
def show
|
||||
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
|
||||
end
|
||||
|
||||
def create
|
||||
@assignment_policy = Current.account.assignment_policies.build(assignment_policy_params)
|
||||
|
||||
if @assignment_policy.save
|
||||
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }, status: :created
|
||||
else
|
||||
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @assignment_policy.update(assignment_policy_params)
|
||||
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
|
||||
else
|
||||
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @assignment_policy.destroy
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(params[:id])
|
||||
end
|
||||
|
||||
def assignment_policy_params
|
||||
params.require(:assignment_policy).permit(
|
||||
:name, :description, :assignment_order, :conversation_priority,
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled
|
||||
)
|
||||
end
|
||||
|
||||
def serialize_assignment_policy(policy)
|
||||
{
|
||||
id: policy.id,
|
||||
name: policy.name,
|
||||
description: policy.description,
|
||||
assignment_order: policy.assignment_order,
|
||||
conversation_priority: policy.conversation_priority,
|
||||
fair_distribution_limit: policy.fair_distribution_limit,
|
||||
fair_distribution_window: policy.fair_distribution_window,
|
||||
enabled: policy.enabled,
|
||||
inbox_count: policy.inboxes.count,
|
||||
inboxes: policy.inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
|
||||
created_at: policy.created_at,
|
||||
updated_at: policy.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_assignment_policies(policies)
|
||||
policies.map { |policy| serialize_assignment_policy(policy) }
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(AssignmentPolicy)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::InboxAssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inbox
|
||||
before_action :check_authorization
|
||||
|
||||
def show
|
||||
@inbox_assignment_policy = @inbox.inbox_assignment_policy
|
||||
|
||||
if @inbox_assignment_policy
|
||||
render json: {
|
||||
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
|
||||
}
|
||||
else
|
||||
render json: {
|
||||
inbox_assignment_policy: nil,
|
||||
message: 'No assignment policy assigned to this inbox'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
# Remove existing assignment if any
|
||||
@inbox.inbox_assignment_policy&.destroy
|
||||
|
||||
@assignment_policy = Current.account.assignment_policies.find(params[:assignment_policy_id])
|
||||
@inbox_assignment_policy = @inbox.build_inbox_assignment_policy(assignment_policy: @assignment_policy)
|
||||
|
||||
if @inbox_assignment_policy.save
|
||||
render json: {
|
||||
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
|
||||
}, status: :created
|
||||
else
|
||||
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@inbox_assignment_policy = @inbox.inbox_assignment_policy
|
||||
|
||||
if @inbox_assignment_policy
|
||||
if @inbox_assignment_policy.destroy
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
render json: { error: 'No assignment policy found for this inbox' }, status: :not_found
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(@inbox, :update?)
|
||||
end
|
||||
|
||||
def serialize_inbox_assignment_policy(inbox_assignment_policy)
|
||||
{
|
||||
id: inbox_assignment_policy.id,
|
||||
inbox_id: inbox_assignment_policy.inbox_id,
|
||||
assignment_policy_id: inbox_assignment_policy.assignment_policy_id,
|
||||
assignment_policy: {
|
||||
id: inbox_assignment_policy.assignment_policy.id,
|
||||
name: inbox_assignment_policy.assignment_policy.name,
|
||||
description: inbox_assignment_policy.assignment_policy.description,
|
||||
assignment_order: inbox_assignment_policy.assignment_policy.assignment_order,
|
||||
conversation_priority: inbox_assignment_policy.assignment_policy.conversation_priority,
|
||||
fair_distribution_limit: inbox_assignment_policy.assignment_policy.fair_distribution_limit,
|
||||
fair_distribution_window: inbox_assignment_policy.assignment_policy.fair_distribution_window,
|
||||
enabled: inbox_assignment_policy.assignment_policy.enabled
|
||||
},
|
||||
created_at: inbox_assignment_policy.created_at,
|
||||
updated_at: inbox_assignment_policy.updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,148 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::LeavesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_leave, only: [:show, :update, :destroy, :approve, :reject]
|
||||
before_action -> { check_authorization(Leave) }, only: [:index, :create]
|
||||
before_action :authorize_leave, only: [:show, :update, :destroy]
|
||||
before_action :authorize_approval, only: [:approve, :reject]
|
||||
|
||||
def index
|
||||
@leaves = leave_service.list(filter_params)
|
||||
render json: { leaves: serialize_leaves(@leaves) }
|
||||
end
|
||||
|
||||
def show
|
||||
render json: { leave: serialize_leave(@leave) }
|
||||
end
|
||||
|
||||
def create
|
||||
account_user = find_or_authorize_account_user
|
||||
service = Leaves::LeaveService.new(
|
||||
account: Current.account,
|
||||
account_user: account_user,
|
||||
current_user: Current.user
|
||||
)
|
||||
|
||||
result = service.create(leave_params)
|
||||
|
||||
if result[:success]
|
||||
render json: { leave: serialize_leave(result[:leave]) }, status: :created
|
||||
else
|
||||
render json: { errors: result[:errors] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
result = leave_service.update(@leave, leave_params)
|
||||
|
||||
if result[:success]
|
||||
render json: { leave: serialize_leave(result[:leave]) }
|
||||
else
|
||||
render json: { errors: result[:errors] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @leave.destroy
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: @leave.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def approve
|
||||
service = Leaves::LeaveApprovalService.new(leave: @leave, approver: Current.user)
|
||||
result = service.approve(params[:comments])
|
||||
|
||||
if result[:success]
|
||||
render json: { leave: serialize_leave(result[:leave]) }
|
||||
else
|
||||
render json: { errors: result[:errors] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def reject
|
||||
service = Leaves::LeaveApprovalService.new(leave: @leave, approver: Current.user)
|
||||
result = service.reject(params[:reason])
|
||||
|
||||
if result[:success]
|
||||
render json: { leave: serialize_leave(result[:leave]) }
|
||||
else
|
||||
render json: { errors: result[:errors] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_leave
|
||||
@leave = Current.account.leaves.find(params[:id])
|
||||
end
|
||||
|
||||
def authorize_leave
|
||||
authorize @leave
|
||||
end
|
||||
|
||||
def authorize_approval
|
||||
authorize @leave, :approve?
|
||||
end
|
||||
|
||||
def find_or_authorize_account_user
|
||||
if params[:user_id].present? && Current.account_user.administrator?
|
||||
user = Current.account.users.find(params[:user_id])
|
||||
Current.account.account_users.find_by!(user: user)
|
||||
else
|
||||
Current.account.account_users.find_by!(user: Current.user)
|
||||
end
|
||||
end
|
||||
|
||||
def leave_service
|
||||
@leave_service ||= if @leave
|
||||
Leaves::LeaveService.new(
|
||||
account: Current.account,
|
||||
account_user: @leave.account_user,
|
||||
current_user: Current.user
|
||||
)
|
||||
else
|
||||
# For index action, we don't have a specific leave
|
||||
Leaves::LeaveService.new(
|
||||
account: Current.account,
|
||||
account_user: nil,
|
||||
current_user: Current.user
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def leave_params
|
||||
params.require(:leave).permit(:start_date, :end_date, :leave_type, :reason)
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.permit(:status, :leave_type, :start_date, :end_date, :user_id)
|
||||
end
|
||||
|
||||
def serialize_leave(leave)
|
||||
{
|
||||
id: leave.id,
|
||||
start_date: leave.start_date,
|
||||
end_date: leave.end_date,
|
||||
leave_type: leave.leave_type,
|
||||
status: leave.status,
|
||||
reason: leave.reason,
|
||||
days_count: leave.days_count,
|
||||
approved_by: leave.approved_by&.name,
|
||||
approved_at: leave.approved_at,
|
||||
user: {
|
||||
id: leave.user.id,
|
||||
name: leave.user.name,
|
||||
email: leave.user.email,
|
||||
avatar_url: leave.user.avatar_url
|
||||
},
|
||||
created_at: leave.created_at,
|
||||
updated_at: leave.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_leaves(leaves)
|
||||
leaves.map { |leave| serialize_leave(leave) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,101 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::Reports::AssignmentMetricsController < Api::V1::Accounts::BaseController
|
||||
include AssignmentMetricsHelper
|
||||
include DistributionMetrics
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :validate_date_range
|
||||
|
||||
def index
|
||||
@metrics = metrics_service.compute_assignment_metrics
|
||||
render json: { assignment_metrics: @metrics }
|
||||
end
|
||||
|
||||
def agent_history
|
||||
history_service = Reports::AgentHistoryService.new(Current.account, params)
|
||||
|
||||
if params[:agent_id].present?
|
||||
@agent = Current.account.users.find(params[:agent_id])
|
||||
@assignment_history = history_service.fetch_agent_assignment_history(@agent)
|
||||
|
||||
render json: {
|
||||
agent: serialize_agent(@agent),
|
||||
assignment_history: @assignment_history,
|
||||
meta: pagination_meta
|
||||
}
|
||||
else
|
||||
agents_summary = history_service.compute_all_agents_history
|
||||
render json: { agents_history: agents_summary }
|
||||
end
|
||||
end
|
||||
|
||||
def policy_performance
|
||||
@policies = Current.account.assignment_policies.includes(:inboxes)
|
||||
performance_data = @policies.map do |policy|
|
||||
metrics_service.compute_policy_performance(policy)
|
||||
end
|
||||
|
||||
render json: { policy_performance: performance_data }
|
||||
end
|
||||
|
||||
def agent_utilization
|
||||
agents = Current.account.users.joins(:account_users).where(account_users: { role: %w[agent administrator] })
|
||||
utilization_data = agents.map do |agent|
|
||||
metrics_service.compute_agent_utilization(agent)
|
||||
end
|
||||
|
||||
render json: { agent_utilization: utilization_data }
|
||||
end
|
||||
|
||||
def assignment_distribution
|
||||
distribution_data = {
|
||||
by_inbox: metrics_service.compute_distribution_by_inbox,
|
||||
by_team: metrics_service.compute_distribution_by_team,
|
||||
by_hour: metrics_service.compute_distribution_by_hour,
|
||||
by_day_of_week: compute_distribution_by_day_of_week
|
||||
}
|
||||
|
||||
render json: { assignment_distribution: distribution_data }
|
||||
end
|
||||
|
||||
def export
|
||||
type = params[:type] || 'csv'
|
||||
data = metrics_service.compute_assignment_metrics
|
||||
|
||||
case type
|
||||
when 'csv'
|
||||
export_service = Reports::AssignmentExportService.new(data)
|
||||
send_data export_service.generate_csv, filename: "assignment_metrics_#{Date.current}.csv", type: 'text/csv'
|
||||
when 'json'
|
||||
send_data data.to_json, filename: "assignment_metrics_#{Date.current}.json", type: 'application/json'
|
||||
else
|
||||
render json: { error: 'Unsupported export type' }, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_date_range
|
||||
return if params[:since].blank? || params[:until].blank?
|
||||
|
||||
start_date = Date.parse(params[:since])
|
||||
end_date = Date.parse(params[:until])
|
||||
|
||||
if start_date > end_date
|
||||
render json: { error: 'Start date must be before end date' }, status: :unprocessable_entity
|
||||
elsif (end_date - start_date).to_i > 365
|
||||
render json: { error: 'Date range cannot exceed 365 days' }, status: :unprocessable_entity
|
||||
end
|
||||
rescue Date::Error
|
||||
render json: { error: 'Invalid date format' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize Current.account, :show_metrics?
|
||||
end
|
||||
|
||||
def metrics_service
|
||||
@metrics_service ||= Reports::AssignmentMetricsService.new(Current.account, params)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,117 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AssignmentMetricsHelper
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def compute_summary_metrics
|
||||
conversations = filter_conversations_by_date_range
|
||||
|
||||
{
|
||||
total_assignments: conversations.count,
|
||||
average_assignment_time: calculate_average_assignment_time(conversations),
|
||||
assignments_per_agent: calculate_assignments_per_agent(conversations),
|
||||
unassigned_conversations: Current.account.conversations.open.unassigned.count,
|
||||
policies_active: Current.account.assignment_policies.enabled.count
|
||||
}
|
||||
end
|
||||
|
||||
def compute_inbox_metrics
|
||||
inboxes = params[:inbox_id].present? ? Current.account.inboxes.where(id: params[:inbox_id]) : Current.account.inboxes
|
||||
|
||||
inboxes.map do |inbox|
|
||||
conversations = filter_conversations_by_date_range.where(inbox_id: inbox.id)
|
||||
{
|
||||
inbox_id: inbox.id,
|
||||
inbox_name: inbox.name,
|
||||
total_assignments: conversations.count,
|
||||
average_assignment_time: calculate_average_assignment_time(conversations),
|
||||
unique_agents: conversations.where.not(assignee_id: nil).distinct.count(:assignee_id),
|
||||
assignment_policy: inbox.assignment_policy&.name
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def compute_agent_metrics
|
||||
agents = Current.account.users.joins(:account_users).where(account_users: { role: %w[agent administrator] })
|
||||
|
||||
agents.map do |agent|
|
||||
conversations = filter_conversations_by_date_range.where(assignee_id: agent.id)
|
||||
{
|
||||
agent_id: agent.id,
|
||||
agent_name: agent.name,
|
||||
agent_email: agent.email,
|
||||
assignment_count: conversations.count,
|
||||
average_resolution_time: calculate_average_resolution_time(conversations),
|
||||
current_load: agent.assigned_conversations.open.count,
|
||||
capacity_utilization: compute_agent_capacity_utilization(agent)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def compute_policy_metrics
|
||||
Current.account.assignment_policies.enabled.map do |policy|
|
||||
compute_policy_performance(policy)
|
||||
end
|
||||
end
|
||||
|
||||
def filter_conversations_by_date_range
|
||||
Current.account.conversations.where(created_at: date_range)
|
||||
end
|
||||
|
||||
def date_range
|
||||
@date_range ||= params[:since]..params[:until]
|
||||
end
|
||||
|
||||
def calculate_average_assignment_time(_conversations)
|
||||
# Implementation
|
||||
0
|
||||
end
|
||||
|
||||
def calculate_assignments_per_agent(conversations)
|
||||
agents_count = conversations.where.not(assignee_id: nil).distinct.count(:assignee_id)
|
||||
return 0 if agents_count.zero?
|
||||
|
||||
(conversations.count.to_f / agents_count).round(2)
|
||||
end
|
||||
|
||||
def calculate_average_resolution_time(_conversations)
|
||||
# Implementation
|
||||
0
|
||||
end
|
||||
|
||||
def compute_agent_capacity_utilization(_agent)
|
||||
# Implementation
|
||||
0.0
|
||||
end
|
||||
|
||||
def calculate_assignment_success_rate(_conversations)
|
||||
# Implementation
|
||||
100.0
|
||||
end
|
||||
|
||||
def serialize_agent(agent)
|
||||
{
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
email: agent.email
|
||||
}
|
||||
end
|
||||
|
||||
def group_by_hour(conversations)
|
||||
conversations.group_by_hour(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_day(conversations)
|
||||
conversations.group_by_day(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_week(conversations)
|
||||
conversations.group_by_week(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_month(conversations)
|
||||
conversations.group_by_month(:created_at).count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DistributionMetrics
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def compute_distribution_by_day_of_week
|
||||
filter_conversations_by_date_range
|
||||
.group_by_day_of_week(:created_at)
|
||||
.count
|
||||
end
|
||||
|
||||
def compute_policy_performance(policy)
|
||||
inbox_ids = policy.inboxes.pluck(:id)
|
||||
conversations = filter_conversations_by_date_range.where(inbox_id: inbox_ids)
|
||||
|
||||
{
|
||||
policy_id: policy.id,
|
||||
policy_name: policy.name,
|
||||
assignment_order: policy.assignment_order,
|
||||
total_assignments: conversations.count,
|
||||
average_assignment_time: calculate_average_assignment_time(conversations),
|
||||
success_rate: calculate_assignment_success_rate(conversations),
|
||||
inbox_count: inbox_ids.count
|
||||
}
|
||||
end
|
||||
|
||||
def compute_agent_utilization(agent)
|
||||
capacity_policy = agent.account_users.first&.agent_capacity_policy
|
||||
assigned_conversations = agent.assigned_conversations.open.count
|
||||
|
||||
utilization = if capacity_policy
|
||||
policy_limit = capacity_policy.inbox_capacity_limits.sum(:conversation_limit)
|
||||
policy_limit.positive? ? (assigned_conversations.to_f / policy_limit * 100).round(2) : 0
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
{
|
||||
agent: serialize_agent(agent),
|
||||
assigned_conversations: assigned_conversations,
|
||||
capacity_policy: capacity_policy&.name,
|
||||
utilization_percentage: utilization,
|
||||
available_capacity: capacity_policy ? capacity_policy.inbox_capacity_limits.sum(:conversation_limit) - assigned_conversations : nil
|
||||
}
|
||||
end
|
||||
|
||||
def compute_distribution_by_inbox
|
||||
filter_conversations_by_date_range
|
||||
.joins(:inbox)
|
||||
.group('inboxes.id', 'inboxes.name')
|
||||
.count
|
||||
.map { |k, v| { inbox_id: k[0], inbox_name: k[1], count: v } }
|
||||
end
|
||||
|
||||
def compute_distribution_by_team
|
||||
filter_conversations_by_date_range
|
||||
.joins(assignee: { team_members: :team })
|
||||
.group('teams.id', 'teams.name')
|
||||
.count
|
||||
.map { |k, v| { team_id: k[0], team_name: k[1], count: v } }
|
||||
end
|
||||
|
||||
def compute_distribution_by_hour
|
||||
filter_conversations_by_date_range
|
||||
.group_by_hour_of_day(:created_at)
|
||||
.count
|
||||
end
|
||||
|
||||
def calculate_average_resolution_time(conversations)
|
||||
resolved = conversations.resolved
|
||||
return 0 if resolved.empty?
|
||||
|
||||
total_time = resolved.sum { |c| (c.last_activity_at - c.created_at) / 1.hour }
|
||||
(total_time / resolved.count).round(2)
|
||||
end
|
||||
|
||||
def calculate_assignment_success_rate(conversations)
|
||||
total = conversations.count
|
||||
return 0.0 if total.zero?
|
||||
|
||||
successful = conversations.where(status: %w[resolved snoozed]).count
|
||||
(successful.to_f / total * 100).round(2)
|
||||
end
|
||||
|
||||
def pagination_meta
|
||||
{
|
||||
current_page: params[:page] || 1,
|
||||
per_page: params[:per_page] || 50,
|
||||
total_count: @assignment_history&.total_count || 0
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, h } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import {
|
||||
useVueTable,
|
||||
createColumnHelper,
|
||||
getCoreRowModel,
|
||||
} from '@tanstack/vue-table';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import BasePaywallModal from '../../components/BasePaywallModal.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Table from 'dashboard/components/table/Table.vue';
|
||||
import ConfirmationModal from 'dashboard/components/widgets/modal/ConfirmationModal.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const policies = computed(
|
||||
() => getters['agentCapacity/getCapacityPolicies'].value
|
||||
);
|
||||
// Removed agentCapacities as agents tab is removed
|
||||
const uiFlags = computed(() => getters['agentCapacity/getUIFlags'].value);
|
||||
// Removed agents computed property as agents tab is removed
|
||||
|
||||
// Removed activeTab and tabs since we only have policies now
|
||||
const loading = ref({});
|
||||
const showDeletePopup = ref(false);
|
||||
const showPaywallModal = ref(false);
|
||||
const selectedPolicy = ref(null);
|
||||
|
||||
// Column helpers for TanStack Table
|
||||
const policyColumnHelper = createColumnHelper();
|
||||
const policyColumns = [
|
||||
policyColumnHelper.accessor('name', {
|
||||
header: 'Policy Name',
|
||||
cell: info =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'font-medium text-slate-900' },
|
||||
info.getValue() || 'Untitled Policy'
|
||||
),
|
||||
size: 250,
|
||||
}),
|
||||
policyColumnHelper.accessor('description', {
|
||||
header: 'Description',
|
||||
cell: info =>
|
||||
h('div', { class: 'text-sm text-slate-600' }, info.getValue() || '-'),
|
||||
size: 350,
|
||||
}),
|
||||
policyColumnHelper.accessor('inbox_limits', {
|
||||
header: 'Inbox Limits',
|
||||
cell: info => {
|
||||
const inboxLimits = info.getValue() || [];
|
||||
const count = Array.isArray(inboxLimits) ? inboxLimits.length : 0;
|
||||
return h('span', { class: 'font-medium' }, count);
|
||||
},
|
||||
size: 150,
|
||||
}),
|
||||
policyColumnHelper.accessor('user_count', {
|
||||
header: 'Assigned Agents',
|
||||
cell: info =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class:
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800',
|
||||
},
|
||||
`${info.getValue() || 0} ${info.getValue() === 1 ? 'Agent' : 'Agents'}`
|
||||
),
|
||||
size: 150,
|
||||
}),
|
||||
policyColumnHelper.accessor('actions', {
|
||||
header: 'Actions',
|
||||
cell: info =>
|
||||
h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
icon: 'edit',
|
||||
onClick: () => editPolicy(info.row.original),
|
||||
},
|
||||
() => 'Edit'
|
||||
),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
color: 'ruby',
|
||||
size: 'sm',
|
||||
icon: 'delete',
|
||||
isLoading: loading.value[info.row.original.id],
|
||||
onClick: () => openDeletePopup(info.row.original),
|
||||
},
|
||||
() => 'Delete'
|
||||
),
|
||||
]),
|
||||
size: 100,
|
||||
}),
|
||||
];
|
||||
|
||||
// Removed agent columns as agents tab is removed
|
||||
|
||||
const fetchData = async () => {
|
||||
// Only fetch policies since agents tab is removed
|
||||
await store.dispatch('agentCapacity/get');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
|
||||
const navigateToNew = () => {
|
||||
router.push({ name: 'assignment_capacity_new' });
|
||||
};
|
||||
|
||||
const editPolicy = policy => {
|
||||
router.push({
|
||||
name: 'assignment_capacity_edit',
|
||||
params: { id: policy.id },
|
||||
});
|
||||
};
|
||||
|
||||
const openDeletePopup = policy => {
|
||||
selectedPolicy.value = policy;
|
||||
showDeletePopup.value = true;
|
||||
};
|
||||
|
||||
const closeDeletePopup = () => {
|
||||
selectedPolicy.value = null;
|
||||
showDeletePopup.value = false;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedPolicy.value) return;
|
||||
|
||||
try {
|
||||
loading.value[selectedPolicy.value.id] = true;
|
||||
await store.dispatch('agentCapacity/delete', selectedPolicy.value.id);
|
||||
useAlert('Capacity policy deleted successfully');
|
||||
closeDeletePopup();
|
||||
} catch (error) {
|
||||
useAlert('Failed to delete capacity policy');
|
||||
} finally {
|
||||
loading.value[selectedPolicy.value.id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Removed utilization helper functions as agents tab is removed
|
||||
|
||||
// Table instances
|
||||
const policyTable = computed(() =>
|
||||
useVueTable({
|
||||
data: policies.value,
|
||||
columns: policyColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
);
|
||||
|
||||
// Removed agentTable as agents tab is removed
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
title="Agent Capacity Management"
|
||||
description="Set conversation limits and manage agent workload policies"
|
||||
back-button-label="Back to Assignment"
|
||||
@back="$router.push({ name: 'assignment_index' })"
|
||||
>
|
||||
<template #actions>
|
||||
<Button variant="primary" icon="add" @click="navigateToNew">
|
||||
{{ $t('ASSIGNMENT_SETTINGS.CAPACITY.NEW_BUTTON') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
|
||||
<div class="p-8">
|
||||
<!-- Removed tab switching UI since we only have policies now -->
|
||||
|
||||
<div
|
||||
v-if="uiFlags.isFetching"
|
||||
class="flex items-center justify-center h-64"
|
||||
>
|
||||
<Spinner :size="48" />
|
||||
</div>
|
||||
|
||||
<!-- Policies -->
|
||||
<div v-else>
|
||||
<EmptyState
|
||||
v-if="!policies.length"
|
||||
title="No Capacity Policies"
|
||||
message="Create capacity policies to manage agent workload and conversation limits"
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
icon="add"
|
||||
@click="navigateToNew"
|
||||
>
|
||||
Create Capacity Policy
|
||||
</Button>
|
||||
</EmptyState>
|
||||
|
||||
<Table v-else :table="policyTable" />
|
||||
</div>
|
||||
|
||||
<!-- Agents Tab removed as requested -->
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
v-model:show="showDeletePopup"
|
||||
title="Delete Capacity Policy"
|
||||
:description="`Are you sure you want to delete the capacity policy '${selectedPolicy?.name}'? This action cannot be undone.`"
|
||||
confirm-label="Delete"
|
||||
cancel-label="Cancel"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="closeDeletePopup"
|
||||
/>
|
||||
|
||||
<!-- Removed BasePaywallModal for Enterprise accounts -->
|
||||
</div>
|
||||
</template>
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import BasePaywallModal from '../../components/BasePaywallModal.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components/widgets/forms/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const isEditMode = computed(() => !!route.params.id);
|
||||
const policyId = computed(() => route.params.id);
|
||||
|
||||
const uiFlags = computed(() => getters['agentCapacity/getUIFlags'].value);
|
||||
const agents = computed(() => getters['agents/getAgents'].value);
|
||||
const inboxes = computed(() => getters['inboxes/getInboxes'].value);
|
||||
|
||||
const loading = ref(false);
|
||||
const showPaywallModal = ref(false);
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
exclusion_rules: {
|
||||
labels: [],
|
||||
hours_threshold: 24,
|
||||
},
|
||||
inbox_limits: [], // Array of { inbox_id, conversation_limit }
|
||||
selected_agents: [], // For MultiSelect component
|
||||
});
|
||||
|
||||
const errors = ref({});
|
||||
|
||||
const addInboxLimit = () => {
|
||||
formData.value.inbox_limits.push({
|
||||
inbox_id: '',
|
||||
conversation_limit: 10,
|
||||
});
|
||||
};
|
||||
|
||||
const removeInboxLimit = index => {
|
||||
formData.value.inbox_limits.splice(index, 1);
|
||||
};
|
||||
|
||||
const loadPolicy = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const policy = await store.dispatch('agentCapacity/show', policyId.value);
|
||||
formData.value = {
|
||||
name: policy.name,
|
||||
description: policy.description || '',
|
||||
exclusion_rules: policy.exclusion_rules || {
|
||||
labels: [],
|
||||
hours_threshold: 24,
|
||||
},
|
||||
inbox_limits: Array.isArray(policy.inbox_limits)
|
||||
? policy.inbox_limits
|
||||
: [],
|
||||
selected_agents:
|
||||
policy.users?.map(user => ({ id: user.id, name: user.name })) || [],
|
||||
};
|
||||
} catch (error) {
|
||||
useAlert('Failed to load capacity policy');
|
||||
router.push({ name: 'assignment_capacity_list' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
errors.value = {};
|
||||
|
||||
if (!formData.value.name.trim()) {
|
||||
errors.value.name = 'Policy name is required';
|
||||
}
|
||||
|
||||
if (!formData.value.selected_agents.length) {
|
||||
errors.value.selected_agents = 'At least one agent must be selected';
|
||||
}
|
||||
|
||||
// Validate inbox limits
|
||||
for (let i = 0; i < formData.value.inbox_limits.length; i++) {
|
||||
const limit = formData.value.inbox_limits[i];
|
||||
if (!limit.inbox_id) {
|
||||
errors.value[`inbox_limit_${i}_inbox`] = 'Please select an inbox';
|
||||
}
|
||||
if (!limit.conversation_limit || limit.conversation_limit < 1) {
|
||||
errors.value[`inbox_limit_${i}_limit`] =
|
||||
'Conversation limit must be at least 1';
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(errors.value).length === 0;
|
||||
};
|
||||
|
||||
const savePolicy = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: formData.value.name.trim(),
|
||||
description: formData.value.description.trim(),
|
||||
exclusion_rules: formData.value.exclusion_rules,
|
||||
};
|
||||
|
||||
let policyToUse;
|
||||
if (isEditMode.value) {
|
||||
// Update policy
|
||||
policyToUse = await store.dispatch('agentCapacity/update', {
|
||||
id: policyId.value,
|
||||
...payload,
|
||||
});
|
||||
useAlert('Capacity policy updated successfully');
|
||||
} else {
|
||||
// Create policy
|
||||
policyToUse = await store.dispatch('agentCapacity/create', payload);
|
||||
useAlert('Capacity policy created successfully');
|
||||
}
|
||||
|
||||
const actualPolicyId = policyToUse?.id || policyId.value;
|
||||
|
||||
// Handle inbox limits
|
||||
if (isEditMode.value) {
|
||||
// For editing, handle both additions and removals
|
||||
const updatedPolicy = await store.dispatch(
|
||||
'agentCapacity/show',
|
||||
actualPolicyId
|
||||
);
|
||||
const existingInboxIds =
|
||||
updatedPolicy.inbox_limits?.map(limit => limit.inbox_id) || [];
|
||||
const currentInboxIds = formData.value.inbox_limits
|
||||
.map(limit => limit.inbox_id)
|
||||
.filter(id => id);
|
||||
|
||||
// Remove inbox limits that are no longer configured
|
||||
const inboxesToRemove = existingInboxIds.filter(
|
||||
inboxId => !currentInboxIds.includes(inboxId)
|
||||
);
|
||||
for (const inboxId of inboxesToRemove) {
|
||||
await store.dispatch('agentCapacity/removeInboxLimit', {
|
||||
id: actualPolicyId,
|
||||
inboxId: inboxId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set/update inbox limits
|
||||
for (const limit of formData.value.inbox_limits) {
|
||||
if (limit.inbox_id && limit.conversation_limit) {
|
||||
await store.dispatch('agentCapacity/setInboxLimit', {
|
||||
id: actualPolicyId,
|
||||
inboxId: limit.inbox_id,
|
||||
conversationLimit: limit.conversation_limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle agent assignments
|
||||
const currentAgentIds = formData.value.selected_agents.map(
|
||||
agent => agent.id
|
||||
);
|
||||
|
||||
if (isEditMode.value) {
|
||||
// For editing, we need to handle both additions and removals
|
||||
// Get the current policy state to compare
|
||||
const updatedPolicy = await store.dispatch(
|
||||
'agentCapacity/show',
|
||||
actualPolicyId
|
||||
);
|
||||
const existingAgentIds = updatedPolicy.users?.map(user => user.id) || [];
|
||||
|
||||
// Remove agents that are no longer selected
|
||||
const agentsToRemove = existingAgentIds.filter(
|
||||
agentId => !currentAgentIds.includes(agentId)
|
||||
);
|
||||
for (const agentId of agentsToRemove) {
|
||||
await store.dispatch('agentCapacity/removeUser', {
|
||||
id: actualPolicyId,
|
||||
userId: agentId,
|
||||
});
|
||||
}
|
||||
|
||||
// Add newly selected agents
|
||||
const agentsToAdd = currentAgentIds.filter(
|
||||
agentId => !existingAgentIds.includes(agentId)
|
||||
);
|
||||
for (const agentId of agentsToAdd) {
|
||||
await store.dispatch('agentCapacity/assignUser', {
|
||||
id: actualPolicyId,
|
||||
userId: agentId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For new policies, just assign all selected agents
|
||||
for (const agentId of currentAgentIds) {
|
||||
await store.dispatch('agentCapacity/assignUser', {
|
||||
id: actualPolicyId,
|
||||
userId: agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the capacity policies list to show updated data
|
||||
await store.dispatch('agentCapacity/get');
|
||||
router.push({ name: 'assignment_capacity_list' });
|
||||
} catch (error) {
|
||||
const message = isEditMode.value
|
||||
? 'Failed to update capacity policy'
|
||||
: 'Failed to create capacity policy';
|
||||
useAlert(message);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
router.push({ name: 'assignment_capacity_list' });
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
store.dispatch('agents/get'),
|
||||
store.dispatch('inboxes/get'),
|
||||
]);
|
||||
|
||||
if (isEditMode.value) {
|
||||
await loadPolicy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
:title="isEditMode ? 'Edit Capacity Policy' : 'Create Capacity Policy'"
|
||||
description="Configure conversation limits and agent assignments for this policy"
|
||||
back-button-label="Back to Capacity Management"
|
||||
@back="cancel"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center h-64">
|
||||
<Spinner :size="48" />
|
||||
</div>
|
||||
|
||||
<div v-else class="max-w-3xl p-8">
|
||||
<form @submit.prevent="savePolicy">
|
||||
<div class="space-y-6">
|
||||
<!-- Basic Information -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Basic Information</h3>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
v-model="formData.name"
|
||||
label="Policy Name"
|
||||
placeholder="e.g., Standard Agent Capacity"
|
||||
:error="errors.name"
|
||||
required
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="formData.description"
|
||||
label="Description"
|
||||
placeholder="Describe the purpose of this capacity policy"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inbox-Specific Limits -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium">Inbox Capacity Limits</h3>
|
||||
<Button variant="ghost" icon="add" @click="addInboxLimit">
|
||||
Add Inbox Limit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!formData.inbox_limits.length"
|
||||
class="text-slate-500 text-sm text-center py-8"
|
||||
>
|
||||
No inbox limits configured. Click "Add Inbox Limit" to set
|
||||
capacity limits for specific inboxes.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="(limit, index) in formData.inbox_limits"
|
||||
:key="index"
|
||||
class="flex items-end gap-4 p-4 border border-slate-200 rounded-lg"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium mb-1">Inbox</label>
|
||||
<select
|
||||
v-model="limit.inbox_id"
|
||||
class="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-woot-500"
|
||||
:class="{
|
||||
'border-red-500': errors[`inbox_limit_${index}_inbox`],
|
||||
}"
|
||||
>
|
||||
<option value="">Select an inbox</option>
|
||||
<option
|
||||
v-for="inbox in inboxes"
|
||||
:key="inbox.id"
|
||||
:value="inbox.id"
|
||||
>
|
||||
{{ inbox.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div
|
||||
v-if="errors[`inbox_limit_${index}_inbox`]"
|
||||
class="text-red-500 text-xs mt-1"
|
||||
>
|
||||
{{ errors[`inbox_limit_${index}_inbox`] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-32">
|
||||
<Input
|
||||
v-model.number="limit.conversation_limit"
|
||||
type="number"
|
||||
label="Limit"
|
||||
placeholder="10"
|
||||
:error="errors[`inbox_limit_${index}_limit`]"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="ruby"
|
||||
icon="delete"
|
||||
@click="removeInboxLimit(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exclusion Rules -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Exclusion Rules</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Exclude conversations with labels</label>
|
||||
<Input
|
||||
v-model="formData.exclusion_rules.labels"
|
||||
placeholder="vip, escalated (comma-separated)"
|
||||
help-text="Conversations with these labels won't count towards capacity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model.number="formData.exclusion_rules.hours_threshold"
|
||||
type="number"
|
||||
label="Exclude conversations older than (hours)"
|
||||
placeholder="24"
|
||||
help-text="Conversations created before this many hours ago won't count towards capacity"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Assignment -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Agent Assignment</h3>
|
||||
<MultiSelect
|
||||
v-model="formData.selected_agents"
|
||||
:options="agents"
|
||||
label="Assigned Agents"
|
||||
placeholder="Select agents to apply this policy"
|
||||
:error="errors.selected_agents"
|
||||
track-by="id"
|
||||
label-key="name"
|
||||
required
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
:src="option.avatar_url"
|
||||
:alt="option.name"
|
||||
class="w-6 h-6 rounded-full"
|
||||
/>
|
||||
<span>{{ option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-4">
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Note:</strong>
|
||||
Agents will be limited by the conversation counts configured in
|
||||
this policy. Conversations matching exclusion rules won't count
|
||||
towards their capacity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-8">
|
||||
<Button variant="ghost" @click="cancel"> Cancel </Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
|
||||
>
|
||||
{{ isEditMode ? 'Update Policy' : 'Create Policy' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Removed BasePaywallModal for Enterprise accounts -->
|
||||
</div>
|
||||
</template>
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
// Badge component doesn't exist - will use inline styling instead
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ApprovalModal from './components/ApprovalModal.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const { isAdmin } = useAdmin();
|
||||
const { currentUserId } = useAccount();
|
||||
|
||||
const leaveId = computed(() => route.params.id);
|
||||
const leave = computed(() => getters['leaves/getLeave'].value(leaveId.value));
|
||||
|
||||
const loading = ref(true);
|
||||
const showApprovalModal = ref(false);
|
||||
const approvalAction = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.dispatch('leaves/show', leaveId.value);
|
||||
} catch (error) {
|
||||
useAlert('Failed to load leave details');
|
||||
router.push({ name: 'assignment_leaves_list' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Removed getStatusBadgeVariant as we're using inline classes now
|
||||
|
||||
const goBack = () => {
|
||||
router.push({ name: 'assignment_leaves_list' });
|
||||
};
|
||||
|
||||
const openApprovalModal = action => {
|
||||
approvalAction.value = action;
|
||||
showApprovalModal.value = true;
|
||||
};
|
||||
|
||||
const closeApprovalModal = () => {
|
||||
approvalAction.value = '';
|
||||
showApprovalModal.value = false;
|
||||
};
|
||||
|
||||
const handleApproval = async notes => {
|
||||
try {
|
||||
if (approvalAction.value === 'approve') {
|
||||
await store.dispatch('leaves/approve', {
|
||||
id: leaveId.value,
|
||||
comments: notes,
|
||||
});
|
||||
useAlert('Leave request approved successfully');
|
||||
} else {
|
||||
await store.dispatch('leaves/reject', {
|
||||
id: leaveId.value,
|
||||
reason: notes,
|
||||
});
|
||||
useAlert('Leave request rejected successfully');
|
||||
}
|
||||
closeApprovalModal();
|
||||
} catch (error) {
|
||||
const message =
|
||||
approvalAction.value === 'approve'
|
||||
? 'Failed to approve leave request'
|
||||
: 'Failed to reject leave request';
|
||||
useAlert(`${message}: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const canApproveReject = computed(() => {
|
||||
return isAdmin.value && leave.value?.status === 'pending';
|
||||
});
|
||||
|
||||
const canDelete = computed(() => {
|
||||
return (
|
||||
leave.value?.status === 'pending' &&
|
||||
(isAdmin.value || leave.value?.agent_id === currentUserId.value)
|
||||
);
|
||||
});
|
||||
|
||||
const deleteLeave = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Are you sure you want to delete this leave request? This action cannot be undone.'
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
await store.dispatch('leaves/delete', leaveId.value);
|
||||
useAlert('Leave request deleted successfully');
|
||||
router.push({ name: 'assignment_leaves_list' });
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
`Failed to delete leave request: ${error.message || 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
title="Leave Details"
|
||||
description="View complete information about this leave request."
|
||||
back-button-label="Back to Leaves"
|
||||
@back="goBack"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center h-64">
|
||||
<Spinner size="large" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="leave" class="max-w-4xl p-8">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200">
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-slate-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<img
|
||||
:src="leave.agent?.avatar_url"
|
||||
:alt="leave.agent?.name"
|
||||
class="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">{{ leave.agent?.name }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ leave.agent?.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
|
||||
:class="[
|
||||
leave.status === 'approved'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: leave.status === 'rejected'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: leave.status === 'cancelled'
|
||||
? 'bg-slate-100 text-slate-800'
|
||||
: 'bg-yellow-100 text-yellow-800',
|
||||
]"
|
||||
>
|
||||
{{ leave.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Leave Type -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-slate-500 mb-1">Leave Type</h3>
|
||||
<p class="text-lg font-medium capitalize">
|
||||
{{ leave.leave_type }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dates -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-slate-500 mb-1">Dates</h3>
|
||||
<p class="text-lg">
|
||||
{{ new Date(leave.start_date).toLocaleDateString() }} -
|
||||
{{ new Date(leave.end_date).toLocaleDateString() }}
|
||||
</p>
|
||||
<p class="text-sm text-slate-600 mt-1">
|
||||
{{
|
||||
leave.total_days ||
|
||||
Math.ceil(
|
||||
(new Date(leave.end_date) - new Date(leave.start_date)) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
) + 1
|
||||
}}
|
||||
days
|
||||
<span v-if="leave.is_half_day">
|
||||
({{ leave.half_day_period }})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-slate-500 mb-1">Reason</h3>
|
||||
<p class="text-base whitespace-pre-wrap">{{ leave.reason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Request Info -->
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-slate-500 mb-1">
|
||||
Requested On
|
||||
</h3>
|
||||
<p class="text-base">
|
||||
{{ new Date(leave.created_at).toLocaleDateString() }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="leave.status !== 'pending'">
|
||||
<h3 class="text-sm font-medium text-slate-500 mb-1">
|
||||
{{
|
||||
leave.status === 'approved' ? 'Approved By' : 'Rejected By'
|
||||
}}
|
||||
</h3>
|
||||
<p class="text-base">
|
||||
{{ leave.approved_by?.name || 'System' }}
|
||||
<span class="text-sm text-slate-600">
|
||||
({{ new Date(leave.updated_at).toLocaleDateString() }})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Removed approver notes section - field doesn't exist in API -->
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="p-6 border-t border-slate-200">
|
||||
<div class="flex justify-between">
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
variant="danger"
|
||||
color-scheme="secondary"
|
||||
icon="delete"
|
||||
@click="deleteLeave"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<div v-if="canApproveReject" class="flex gap-3 ml-auto">
|
||||
<Button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
@click="openApprovalModal('reject')"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button variant="primary" @click="openApprovalModal('approve')">
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApprovalModal
|
||||
v-model:show="showApprovalModal"
|
||||
:action="approvalAction"
|
||||
:leave="leave"
|
||||
@confirm="handleApproval"
|
||||
@cancel="closeApprovalModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
// Use native select element instead
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
// Removed DatePicker - using native HTML date inputs instead
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { currentUserId } = useAccount();
|
||||
|
||||
const formData = ref({
|
||||
agent_id: currentUserId?.value || null,
|
||||
leave_type: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const errors = ref({});
|
||||
const loading = ref(false);
|
||||
|
||||
const leaveTypes = [
|
||||
{ value: 'vacation', label: 'Vacation' },
|
||||
{ value: 'sick', label: 'Sick Leave' },
|
||||
{ value: 'personal', label: 'Personal' },
|
||||
{ value: 'maternity', label: 'Maternity' },
|
||||
{ value: 'paternity', label: 'Paternity' },
|
||||
{ value: 'unpaid', label: 'Unpaid' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
// Removed half day options - not supported by API
|
||||
|
||||
const totalDays = computed(() => {
|
||||
if (!formData.value.start_date || !formData.value.end_date) return 0;
|
||||
|
||||
const start = new Date(formData.value.start_date);
|
||||
const end = new Date(formData.value.end_date);
|
||||
const diffTime = Math.abs(end - start);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
return diffDays;
|
||||
});
|
||||
|
||||
const validateForm = () => {
|
||||
errors.value = {};
|
||||
|
||||
if (!formData.value.leave_type) {
|
||||
errors.value.leave_type = 'Leave type is required';
|
||||
}
|
||||
|
||||
if (!formData.value.start_date) {
|
||||
errors.value.start_date = 'Start date is required';
|
||||
}
|
||||
|
||||
if (!formData.value.end_date) {
|
||||
errors.value.end_date = 'End date is required';
|
||||
}
|
||||
|
||||
if (formData.value.start_date && formData.value.end_date) {
|
||||
const start = new Date(formData.value.start_date);
|
||||
const end = new Date(formData.value.end_date);
|
||||
|
||||
if (start > end) {
|
||||
errors.value.end_date = 'End date must be after start date';
|
||||
}
|
||||
|
||||
// Removed half day validation - not supported by API
|
||||
}
|
||||
|
||||
if (!formData.value.reason) {
|
||||
errors.value.reason = 'Reason is required';
|
||||
}
|
||||
|
||||
return Object.keys(errors.value).length === 0;
|
||||
};
|
||||
|
||||
// Removed handleHalfDayChange - not needed without half day functionality
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
leave: {
|
||||
start_date: formData.value.start_date,
|
||||
end_date: formData.value.end_date,
|
||||
leave_type: formData.value.leave_type,
|
||||
reason: formData.value.reason,
|
||||
},
|
||||
user_id: formData.value.agent_id,
|
||||
};
|
||||
|
||||
await store.dispatch('leaves/create', payload);
|
||||
|
||||
useAlert('Leave request created successfully');
|
||||
router.push({ name: 'assignment_leaves_list' });
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
`Failed to create leave request: ${error.message || 'Unknown error'}`
|
||||
);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
router.push({ name: 'assignment_leaves_list' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
title="Request Leave"
|
||||
description="Create a new leave request. Your manager will be notified for approval."
|
||||
back-button-label="Back to Leaves"
|
||||
@back="cancel"
|
||||
/>
|
||||
|
||||
<div class="max-w-2xl p-8">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 space-y-6"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||
Leave Type
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="formData.leave_type"
|
||||
class="w-full rounded-md border-slate-300 text-sm"
|
||||
:class="{ 'border-red-500': errors.leave_type }"
|
||||
required
|
||||
>
|
||||
<option value="">Select leave type</option>
|
||||
<option
|
||||
v-for="type in leaveTypes"
|
||||
:key="type.value"
|
||||
:value="type.value"
|
||||
>
|
||||
{{ type.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="errors.leave_type" class="mt-1 text-sm text-red-600">
|
||||
{{ errors.leave_type }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Start Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||
Start Date
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.start_date"
|
||||
type="date"
|
||||
class="w-full rounded-md border-slate-300 text-sm"
|
||||
:class="{ 'border-red-500': errors.start_date }"
|
||||
:min="new Date().toISOString().split('T')[0]"
|
||||
required
|
||||
/>
|
||||
<p v-if="errors.start_date" class="mt-1 text-sm text-red-600">
|
||||
{{ errors.start_date }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- End Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||
End Date
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.end_date"
|
||||
type="date"
|
||||
class="w-full rounded-md border-slate-300 text-sm"
|
||||
:class="{ 'border-red-500': errors.end_date }"
|
||||
:min="
|
||||
formData.start_date || new Date().toISOString().split('T')[0]
|
||||
"
|
||||
required
|
||||
/>
|
||||
<p v-if="errors.end_date" class="mt-1 text-sm text-red-600">
|
||||
{{ errors.end_date }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Removed half day functionality - not supported by API -->
|
||||
|
||||
<div>
|
||||
<div class="text-sm text-slate-600 mb-2">
|
||||
Total Days:
|
||||
<span class="font-medium text-slate-900">{{ totalDays }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
v-model="formData.reason"
|
||||
label="Reason"
|
||||
placeholder="Please provide a reason for your leave request"
|
||||
:error="errors.reason"
|
||||
rows="4"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<Button variant="clear" @click="cancel"> Cancel </Button>
|
||||
<Button type="submit" variant="primary" :loading="loading">
|
||||
Submit Request
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+482
@@ -0,0 +1,482 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ConfirmationModal from 'dashboard/components/widgets/modal/ConfirmationModal.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { currentUserId } = useAccount();
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const leaves = computed(() => getters['leaves/getLeaves'].value || []);
|
||||
const uiFlags = computed(() => getters['leaves/getUIFlags'].value || {});
|
||||
|
||||
const activeTab = ref('all');
|
||||
const loading = ref({});
|
||||
const showDeletePopup = ref(false);
|
||||
const selectedLeave = ref(null);
|
||||
|
||||
// Simplified filtered leaves without search
|
||||
const filteredLeaves = computed(() => {
|
||||
if (activeTab.value === 'all') {
|
||||
return leaves.value;
|
||||
}
|
||||
return leaves.value.filter(leave => leave.status === activeTab.value);
|
||||
});
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
key: 'all',
|
||||
label: 'All Leaves',
|
||||
count: leaves.value.length,
|
||||
},
|
||||
{
|
||||
key: 'approved',
|
||||
label: 'Approved Leaves',
|
||||
count: leaves.value.filter(leave => leave.status === 'approved').length,
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: 'Pending Leaves',
|
||||
count: leaves.value.filter(leave => leave.status === 'pending').length,
|
||||
},
|
||||
{
|
||||
key: 'rejected',
|
||||
label: 'Rejected Leaves',
|
||||
count: leaves.value.filter(leave => leave.status === 'rejected').length,
|
||||
},
|
||||
]);
|
||||
|
||||
// Helper functions for card display
|
||||
const getStatusColor = status => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'rejected':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getCompactStatusColor = status => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'bg-green-50 text-green-700 ring-1 ring-inset ring-green-600/20';
|
||||
case 'rejected':
|
||||
return 'bg-red-50 text-red-700 ring-1 ring-inset ring-red-600/20';
|
||||
case 'pending':
|
||||
return 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/20';
|
||||
default:
|
||||
return 'bg-gray-50 text-gray-700 ring-1 ring-inset ring-gray-600/20';
|
||||
}
|
||||
};
|
||||
|
||||
const getCompactLeaveTypeColor = type => {
|
||||
switch (type) {
|
||||
case 'vacation':
|
||||
return 'bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-600/20';
|
||||
case 'sick':
|
||||
return 'bg-red-50 text-red-700 ring-1 ring-inset ring-red-600/20';
|
||||
case 'personal':
|
||||
return 'bg-purple-50 text-purple-700 ring-1 ring-inset ring-purple-600/20';
|
||||
case 'maternity':
|
||||
case 'paternity':
|
||||
return 'bg-pink-50 text-pink-700 ring-1 ring-inset ring-pink-600/20';
|
||||
default:
|
||||
return 'bg-gray-50 text-gray-700 ring-1 ring-inset ring-gray-600/20';
|
||||
}
|
||||
};
|
||||
|
||||
const getLeaveTypeColor = type => {
|
||||
switch (type) {
|
||||
case 'vacation':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'sick':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'personal':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'maternity':
|
||||
case 'paternity':
|
||||
return 'bg-pink-100 text-pink-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateRange = (startDate, endDate) => {
|
||||
const start = new Date(startDate).toLocaleDateString();
|
||||
const end = new Date(endDate).toLocaleDateString();
|
||||
return start === end ? start : `${start} - ${end}`;
|
||||
};
|
||||
|
||||
const formatCompactDateRange = (startDate, endDate) => {
|
||||
const options = { month: 'short', day: 'numeric' };
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const startStr = start.toLocaleDateString('en-US', options);
|
||||
const endStr = end.toLocaleDateString('en-US', options);
|
||||
|
||||
// If same date
|
||||
if (startStr === endStr) {
|
||||
return startStr;
|
||||
}
|
||||
|
||||
// If different years, show year for both
|
||||
if (start.getFullYear() !== end.getFullYear()) {
|
||||
return `${startStr}, ${start.getFullYear()} - ${endStr}, ${end.getFullYear()}`;
|
||||
}
|
||||
|
||||
return `${startStr} - ${endStr}`;
|
||||
};
|
||||
|
||||
const getDaysCount = (startDate, endDate) => {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const diffTime = Math.abs(end - start);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
return diffDays;
|
||||
};
|
||||
|
||||
const fetchLeaves = async () => {
|
||||
// Fetch all leaves without status filter to show all data
|
||||
await store.dispatch('leaves/get', {});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchLeaves();
|
||||
});
|
||||
|
||||
const navigateToNew = () => {
|
||||
router.push({ name: 'assignment_leaves_new' });
|
||||
};
|
||||
|
||||
const viewLeave = leave => {
|
||||
router.push({
|
||||
name: 'assignment_leaves_details',
|
||||
params: { id: leave.id },
|
||||
});
|
||||
};
|
||||
|
||||
const openDeletePopup = leave => {
|
||||
selectedLeave.value = leave;
|
||||
showDeletePopup.value = true;
|
||||
};
|
||||
|
||||
const closeDeletePopup = () => {
|
||||
selectedLeave.value = null;
|
||||
showDeletePopup.value = false;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedLeave.value) return;
|
||||
|
||||
try {
|
||||
loading.value[selectedLeave.value.id] = true;
|
||||
await store.dispatch('leaves/delete', selectedLeave.value.id);
|
||||
useAlert('Leave request deleted successfully');
|
||||
closeDeletePopup();
|
||||
await fetchLeaves(); // Refresh the list
|
||||
} catch (error) {
|
||||
useAlert('Failed to delete leave request');
|
||||
} finally {
|
||||
loading.value[selectedLeave.value.id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get agent name or fallback
|
||||
const getAgentName = leave => {
|
||||
return (
|
||||
leave.user?.name ||
|
||||
leave.agent?.name ||
|
||||
`Agent ${leave.user_id || leave.agent_id || 'Unknown'}`
|
||||
);
|
||||
};
|
||||
|
||||
const getAgentEmail = leave => {
|
||||
return leave.user?.email || leave.agent?.email || '';
|
||||
};
|
||||
|
||||
const getAgentAvatar = leave => {
|
||||
return (
|
||||
leave.user?.avatar_url ||
|
||||
leave.agent?.avatar_url ||
|
||||
'/assets/images/chatwoot_logo.svg'
|
||||
);
|
||||
};
|
||||
|
||||
const canManageLeave = leave => {
|
||||
return isAdmin.value || leave.agent_id === currentUserId.value;
|
||||
};
|
||||
|
||||
const approveLeave = async leave => {
|
||||
if (!window.confirm('Are you sure you want to approve this leave request?'))
|
||||
return;
|
||||
|
||||
try {
|
||||
loading.value[leave.id] = true;
|
||||
await store.dispatch('leaves/approve', {
|
||||
id: leave.id,
|
||||
comments: 'Approved via dashboard',
|
||||
});
|
||||
useAlert('Leave request approved successfully');
|
||||
await fetchLeaves();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
`Failed to approve leave request: ${error.message || 'Unknown error'}`
|
||||
);
|
||||
} finally {
|
||||
loading.value[leave.id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectLeave = async leave => {
|
||||
if (!window.confirm('Are you sure you want to reject this leave request?'))
|
||||
return;
|
||||
|
||||
try {
|
||||
loading.value[leave.id] = true;
|
||||
await store.dispatch('leaves/reject', {
|
||||
id: leave.id,
|
||||
reason: 'Rejected via dashboard',
|
||||
});
|
||||
useAlert('Leave request rejected successfully');
|
||||
await fetchLeaves();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
`Failed to reject leave request: ${error.message || 'Unknown error'}`
|
||||
);
|
||||
} finally {
|
||||
loading.value[leave.id] = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<BaseSettingsHeader
|
||||
title="Leave Management"
|
||||
description="View and manage leave requests for agents. Approved leaves will exclude agents from automatic assignments."
|
||||
back-button-label="Back to Assignment"
|
||||
@back="$router.push({ name: 'assignment_index' })"
|
||||
>
|
||||
<template #actions>
|
||||
<Button variant="primary" icon="add" @click="navigateToNew">
|
||||
Request Leave
|
||||
</Button>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
|
||||
<div class="p-8 space-y-6">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200">
|
||||
<div class="flex border-b border-slate-200">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="flex-1 px-6 py-4 text-sm font-medium text-center transition-all duration-200 relative"
|
||||
:class="[
|
||||
activeTab === tab.key
|
||||
? 'text-blue-600 bg-white'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50',
|
||||
]"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
{{ tab.label }}
|
||||
<span
|
||||
v-if="tab.count > 0"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="
|
||||
activeTab === tab.key
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-slate-100 text-slate-700'
|
||||
"
|
||||
>
|
||||
{{ tab.count }}
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
v-if="activeTab === tab.key"
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="uiFlags.isFetching"
|
||||
class="flex items-center justify-center h-64"
|
||||
>
|
||||
<Spinner :size="48" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<EmptyState
|
||||
v-else-if="!filteredLeaves.length"
|
||||
title="No Leave Requests"
|
||||
message="No leave requests found. Create a new leave request to get started."
|
||||
>
|
||||
<Button variant="solid" size="medium" icon="add" @click="navigateToNew">
|
||||
Request Leave
|
||||
</Button>
|
||||
</EmptyState>
|
||||
|
||||
<!-- Leave Cards - Compact Modern Design -->
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="leave in filteredLeaves"
|
||||
:key="leave.id"
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 hover:shadow-md hover:border-slate-300 transition-all duration-200 cursor-pointer group"
|
||||
@click="viewLeave(leave)"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Avatar -->
|
||||
<img
|
||||
:src="getAgentAvatar(leave)"
|
||||
:alt="getAgentName(leave)"
|
||||
class="w-10 h-10 rounded-full ring-2 ring-white shadow-sm flex-shrink-0"
|
||||
/>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<div class="flex-1">
|
||||
<!-- First Row: Name, Date Range, Days -->
|
||||
<div class="flex items-center gap-2 flex-wrap mb-1">
|
||||
<h3 class="font-semibold text-slate-900">
|
||||
{{ getAgentName(leave) }}
|
||||
</h3>
|
||||
<span class="text-slate-300">•</span>
|
||||
<span class="text-sm text-slate-700">
|
||||
{{
|
||||
formatCompactDateRange(
|
||||
leave.start_date,
|
||||
leave.end_date
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-700"
|
||||
>
|
||||
{{ getDaysCount(leave.start_date, leave.end_date) }}
|
||||
{{
|
||||
getDaysCount(leave.start_date, leave.end_date) === 1
|
||||
? 'day'
|
||||
: 'days'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Second Row: Reason -->
|
||||
<p class="text-sm text-slate-600 line-clamp-1">
|
||||
{{ leave.reason || 'No reason provided' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium capitalize"
|
||||
:class="getCompactLeaveTypeColor(leave.leave_type)"
|
||||
>
|
||||
{{ leave.leave_type }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium capitalize"
|
||||
:class="getCompactStatusColor(leave.status)"
|
||||
>
|
||||
{{ leave.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Row: Meta Info and Actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 text-xs text-slate-500">
|
||||
<span>
|
||||
Requested
|
||||
{{
|
||||
new Date(
|
||||
leave.created_at || leave.start_date
|
||||
).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-if="leave.approved_by?.name">
|
||||
<span class="text-slate-300">•</span>
|
||||
{{
|
||||
leave.status === 'approved' ? 'Approved' : 'Rejected'
|
||||
}}
|
||||
by {{ leave.approved_by.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions (visible on hover) -->
|
||||
<div
|
||||
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<template v-if="leave.status === 'pending' && isAdmin">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="check"
|
||||
color="teal"
|
||||
@click.stop="approveLeave(leave)"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="x"
|
||||
color="ruby"
|
||||
@click.stop="rejectLeave(leave)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
v-if="canManageLeave(leave) && leave.status === 'pending'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="trash"
|
||||
color="ruby"
|
||||
@click.stop="openDeletePopup(leave)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<ConfirmationModal
|
||||
v-model:show="showDeletePopup"
|
||||
title="Delete Leave Request"
|
||||
description="Are you sure you want to delete this leave request? This action cannot be undone."
|
||||
confirm-label="Delete"
|
||||
cancel-label="Cancel"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="closeDeletePopup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Modal from 'dashboard/components/Modal.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
action: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['approve', 'reject'].includes(value),
|
||||
},
|
||||
leave: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'confirm', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const notes = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
// Reset notes when modal opens/closes
|
||||
watch(
|
||||
() => props.show,
|
||||
newValue => {
|
||||
if (!newValue) {
|
||||
notes.value = '';
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const title = computed(() => {
|
||||
return props.action === 'approve'
|
||||
? t('ASSIGNMENT_SETTINGS.LEAVES.APPROVE.MODAL_TITLE')
|
||||
: t('ASSIGNMENT_SETTINGS.LEAVES.REJECT.MODAL_TITLE');
|
||||
});
|
||||
|
||||
const message = computed(() => {
|
||||
if (!props.leave) return '';
|
||||
|
||||
const agentName = props.leave.agent?.name || '';
|
||||
const dates = `${new Date(props.leave.start_date).toLocaleDateString()} - ${new Date(props.leave.end_date).toLocaleDateString()}`;
|
||||
|
||||
return props.action === 'approve'
|
||||
? t('ASSIGNMENT_SETTINGS.LEAVES.APPROVE.MODAL_MESSAGE', {
|
||||
name: agentName,
|
||||
dates,
|
||||
})
|
||||
: t('ASSIGNMENT_SETTINGS.LEAVES.REJECT.MODAL_MESSAGE', {
|
||||
name: agentName,
|
||||
dates,
|
||||
});
|
||||
});
|
||||
|
||||
const confirmText = computed(() => {
|
||||
return props.action === 'approve'
|
||||
? t('ASSIGNMENT_SETTINGS.LEAVES.APPROVE.CONFIRM')
|
||||
: t('ASSIGNMENT_SETTINGS.LEAVES.REJECT.CONFIRM');
|
||||
});
|
||||
|
||||
const notesLabel = computed(() => {
|
||||
return props.action === 'approve'
|
||||
? 'Comments (Optional)'
|
||||
: 'Rejection Reason';
|
||||
});
|
||||
|
||||
const notesPlaceholder = computed(() => {
|
||||
return props.action === 'approve'
|
||||
? 'Add any comments for this approval...'
|
||||
: 'Please provide a reason for rejecting this leave request...';
|
||||
});
|
||||
|
||||
const canConfirm = computed(() => {
|
||||
return (
|
||||
props.action === 'approve' ||
|
||||
(props.action === 'reject' && notes.value.trim())
|
||||
);
|
||||
});
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!canConfirm.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
emit('confirm', notes.value.trim());
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:show', false);
|
||||
notes.value = '';
|
||||
loading.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :show="show" :on-close="handleClose" size="medium">
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center"
|
||||
:class="action === 'approve' ? 'bg-green-100' : 'bg-red-100'"
|
||||
>
|
||||
<i
|
||||
class="text-xl"
|
||||
:class="
|
||||
action === 'approve'
|
||||
? 'icon-checkmark text-green-600'
|
||||
: 'icon-dismiss text-red-600'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-semibold text-slate-900 mb-2">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<p class="text-slate-600 mb-6">
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
<!-- Leave Details Card -->
|
||||
<div
|
||||
v-if="leave"
|
||||
class="bg-slate-50 rounded-lg p-4 mb-6 border border-slate-200"
|
||||
>
|
||||
<h4 class="font-medium text-slate-900 mb-3">Leave Details</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-slate-700">Type:</span>
|
||||
<span class="ml-2 capitalize text-slate-900">{{
|
||||
leave.leave_type
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-slate-700">Duration:</span>
|
||||
<span class="ml-2 text-slate-900">{{ leave.total_days || 'N/A' }} days</span>
|
||||
</div>
|
||||
<div class="col-span-full">
|
||||
<span class="font-medium text-slate-700">Reason:</span>
|
||||
<p class="mt-1 text-slate-600 bg-white p-2 rounded border">
|
||||
{{ leave.reason }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes/Comments -->
|
||||
<div class="mb-6">
|
||||
<TextArea
|
||||
v-model="notes"
|
||||
:label="notesLabel"
|
||||
:placeholder="notesPlaceholder"
|
||||
rows="3"
|
||||
:required="action === 'reject'"
|
||||
/>
|
||||
<div
|
||||
v-if="action === 'reject' && !notes.trim()"
|
||||
class="text-xs text-red-500 mt-1"
|
||||
>
|
||||
Rejection reason is required
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button variant="ghost" :disabled="loading" @click="handleCancel">
|
||||
{{ t('COMMON.CANCEL') }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="action === 'approve' ? 'primary' : 'danger'"
|
||||
:is-loading="loading"
|
||||
:disabled="!canConfirm"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import ReportMetricCard from 'dashboard/routes/dashboard/settings/reports/components/ReportMetricCard.vue';
|
||||
import DateRange from 'dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue';
|
||||
import Agents from 'dashboard/routes/dashboard/settings/reports/components/Filters/Agents.vue';
|
||||
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Table from 'dashboard/components/table/Table.vue';
|
||||
import BarChart from 'shared/components/charts/BarChart.vue';
|
||||
// LineChart and PieChart will be replaced with BarChart
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const metrics = computed(() => getters['assignmentMetrics/getMetrics'].value);
|
||||
const agentHistory = computed(
|
||||
() => getters['assignmentMetrics/getAgentHistory'].value
|
||||
);
|
||||
const policyPerformance = computed(
|
||||
() => getters['assignmentMetrics/getPolicyPerformance'].value
|
||||
);
|
||||
const agentUtilization = computed(
|
||||
() => getters['assignmentMetrics/getAgentUtilization'].value
|
||||
);
|
||||
const uiFlags = computed(() => getters['assignmentMetrics/getUIFlags'].value);
|
||||
const assignmentPolicies = computed(
|
||||
() => getters['assignmentPolicies/getAssignmentPolicies'].value
|
||||
);
|
||||
|
||||
const filters = ref({
|
||||
startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0],
|
||||
endDate: new Date().toISOString().split('T')[0],
|
||||
agentIds: [],
|
||||
policyIds: [],
|
||||
groupBy: 'day',
|
||||
});
|
||||
|
||||
const exportType = ref('overview');
|
||||
const activeTab = ref('overview');
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview', label: t('ASSIGNMENT_SETTINGS.METRICS.TABS.OVERVIEW') },
|
||||
{
|
||||
key: 'agent_performance',
|
||||
label: t('ASSIGNMENT_SETTINGS.METRICS.TABS.AGENT_PERFORMANCE'),
|
||||
},
|
||||
{
|
||||
key: 'policy_performance',
|
||||
label: t('ASSIGNMENT_SETTINGS.METRICS.TABS.POLICY_PERFORMANCE'),
|
||||
},
|
||||
{
|
||||
key: 'utilization',
|
||||
label: t('ASSIGNMENT_SETTINGS.METRICS.TABS.UTILIZATION'),
|
||||
},
|
||||
];
|
||||
|
||||
const agentHistoryColumns = [
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.AGENT'),
|
||||
key: 'agent',
|
||||
width: '25%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.ASSIGNMENTS_HANDLED'),
|
||||
key: 'assignments_handled',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.AVG_RESPONSE_TIME'),
|
||||
key: 'avg_response_time',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.SATISFACTION_SCORE'),
|
||||
key: 'satisfaction_score',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.EFFICIENCY_SCORE'),
|
||||
key: 'efficiency_score',
|
||||
width: '15%',
|
||||
},
|
||||
];
|
||||
|
||||
const policyPerformanceColumns = [
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.POLICY'),
|
||||
key: 'policy',
|
||||
width: '30%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.TOTAL_ASSIGNMENTS'),
|
||||
key: 'total_assignments',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.SUCCESS_RATE'),
|
||||
key: 'success_rate',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.AVG_HANDLING_TIME'),
|
||||
key: 'avg_handling_time',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: t('ASSIGNMENT_SETTINGS.METRICS.TABLE.REASSIGNMENT_RATE'),
|
||||
key: 'reassignment_rate',
|
||||
width: '10%',
|
||||
},
|
||||
];
|
||||
|
||||
const applyFilters = () => {
|
||||
store.dispatch('assignmentMetrics/setFilters', filters.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('assignmentPolicies/get');
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
watch(
|
||||
filters,
|
||||
() => {
|
||||
applyFilters();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const exportReport = async () => {
|
||||
try {
|
||||
await store.dispatch('assignmentMetrics/exportReport', exportType.value);
|
||||
} catch (error) {
|
||||
// Export failed
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = seconds => {
|
||||
if (!seconds) return '-';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
const getAssignmentTrendData = computed(() => {
|
||||
const trends = getters['assignmentMetrics/getAssignmentTrends'].value || [];
|
||||
return {
|
||||
labels: trends.map(trend => new Date(trend.date).toLocaleDateString()),
|
||||
datasets: [
|
||||
{
|
||||
label: t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.ASSIGNMENTS'),
|
||||
data: trends.map(trend => trend.total_assignments),
|
||||
borderColor: 'rgb(99, 102, 241)',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const getUtilizationChartData = computed(() => {
|
||||
const utilization = agentUtilization.value || [];
|
||||
return {
|
||||
labels: utilization.map(a => a.agent_name),
|
||||
datasets: [
|
||||
{
|
||||
label: t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.UTILIZATION'),
|
||||
data: utilization.map(a => a.utilization_percentage),
|
||||
backgroundColor: [
|
||||
'rgba(99, 102, 241, 0.8)',
|
||||
'rgba(34, 197, 94, 0.8)',
|
||||
'rgba(251, 146, 60, 0.8)',
|
||||
'rgba(239, 68, 68, 0.8)',
|
||||
'rgba(168, 85, 247, 0.8)',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const getPolicyDistributionData = computed(() => {
|
||||
const performance = policyPerformance.value || [];
|
||||
return {
|
||||
labels: performance.map(p => p.policy_name),
|
||||
datasets: [
|
||||
{
|
||||
data: performance.map(p => p.total_assignments),
|
||||
backgroundColor: [
|
||||
'rgba(99, 102, 241, 0.8)',
|
||||
'rgba(34, 197, 94, 0.8)',
|
||||
'rgba(251, 146, 60, 0.8)',
|
||||
'rgba(239, 68, 68, 0.8)',
|
||||
'rgba(168, 85, 247, 0.8)',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('ASSIGNMENT_SETTINGS.METRICS.HEADER')"
|
||||
:description="$t('ASSIGNMENT_SETTINGS.METRICS.SUBHEADER')"
|
||||
/>
|
||||
|
||||
<div class="p-8">
|
||||
<!-- Filters -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">
|
||||
{{ $t('ASSIGNMENT_SETTINGS.METRICS.FILTERS.TITLE') }}
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<DateRange
|
||||
v-model:start-date="filters.startDate"
|
||||
v-model:end-date="filters.endDate"
|
||||
/>
|
||||
|
||||
<Agents
|
||||
v-model="filters.agentIds"
|
||||
:label="$t('ASSIGNMENT_SETTINGS.METRICS.FILTERS.AGENTS')"
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
v-model="filters.policyIds"
|
||||
:options="assignmentPolicies"
|
||||
:label="$t('ASSIGNMENT_SETTINGS.METRICS.FILTERS.POLICIES')"
|
||||
:placeholder="
|
||||
$t('ASSIGNMENT_SETTINGS.METRICS.FILTERS.POLICIES_PLACEHOLDER')
|
||||
"
|
||||
track-by="id"
|
||||
label-key="name"
|
||||
/>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
icon="download"
|
||||
:loading="uiFlags.isExporting"
|
||||
@click="exportReport"
|
||||
>
|
||||
{{ $t('ASSIGNMENT_SETTINGS.METRICS.EXPORT') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="flex gap-4 border-b border-slate-200">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="[
|
||||
activeTab === tab.key
|
||||
? 'text-woot-600 border-b-2 border-woot-600'
|
||||
: 'text-slate-600 hover:text-slate-900',
|
||||
]"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="uiFlags.isFetching"
|
||||
class="flex items-center justify-center h-64"
|
||||
>
|
||||
<Spinner size="large" />
|
||||
</div>
|
||||
|
||||
<!-- Overview Tab -->
|
||||
<div v-else-if="activeTab === 'overview'" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<ReportMetricCard
|
||||
:title="$t('ASSIGNMENT_SETTINGS.METRICS.CARDS.TOTAL_ASSIGNMENTS')"
|
||||
:value="
|
||||
(metrics.overview && metrics.overview.total_assignments) || 0
|
||||
"
|
||||
icon="chat"
|
||||
/>
|
||||
|
||||
<ReportMetricCard
|
||||
:title="$t('ASSIGNMENT_SETTINGS.METRICS.CARDS.AVG_RESPONSE_TIME')"
|
||||
:value="
|
||||
formatTime(
|
||||
(metrics.overview && metrics.overview.avg_response_time) || 0
|
||||
)
|
||||
"
|
||||
icon="clock"
|
||||
/>
|
||||
|
||||
<ReportMetricCard
|
||||
:title="$t('ASSIGNMENT_SETTINGS.METRICS.CARDS.SATISFACTION_SCORE')"
|
||||
:value="`${(metrics.overview && metrics.overview.satisfaction_score) || 0}%`"
|
||||
icon="emoji-happy"
|
||||
/>
|
||||
|
||||
<ReportMetricCard
|
||||
:title="$t('ASSIGNMENT_SETTINGS.METRICS.CARDS.REASSIGNMENT_RATE')"
|
||||
:value="`${(metrics.overview && metrics.overview.reassignment_rate) || 0}%`"
|
||||
icon="refresh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
||||
<h3 class="text-lg font-medium mb-4">
|
||||
{{ $t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.ASSIGNMENT_TRENDS') }}
|
||||
</h3>
|
||||
<BarChart
|
||||
:data="getAssignmentTrendData"
|
||||
:chart-options="{ height: 300 }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Performance Tab -->
|
||||
<div v-else-if="activeTab === 'agent_performance'" class="space-y-6">
|
||||
<Table :columns="agentHistoryColumns" :data="agentHistory">
|
||||
<template #agent="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
:src="row.agent_avatar"
|
||||
:alt="row.agent_name"
|
||||
class="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span class="font-medium">{{ row.agent_name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #assignments_handled="{ row }">
|
||||
<span class="font-medium">{{ row.assignments_handled }}</span>
|
||||
</template>
|
||||
|
||||
<template #avg_response_time="{ row }">
|
||||
{{ formatTime(row.avg_response_time) }}
|
||||
</template>
|
||||
|
||||
<template #satisfaction_score="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ row.satisfaction_score }}</span>
|
||||
<div class="w-16 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-green-500"
|
||||
:style="{ width: `${row.satisfaction_score}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #efficiency_score="{ row }">
|
||||
<span
|
||||
class="font-medium"
|
||||
:class="[
|
||||
row.efficiency_score >= 80
|
||||
? 'text-green-600'
|
||||
: row.efficiency_score >= 60
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600',
|
||||
]"
|
||||
>
|
||||
{{ row.efficiency_score }}
|
||||
</span>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Policy Performance Tab -->
|
||||
<div v-else-if="activeTab === 'policy_performance'" class="space-y-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">
|
||||
{{ $t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.POLICY_DISTRIBUTION') }}
|
||||
</h3>
|
||||
<BarChart
|
||||
:data="getPolicyDistributionData"
|
||||
:chart-options="{ height: 300 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">
|
||||
{{
|
||||
$t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.POLICY_SUCCESS_RATES')
|
||||
}}
|
||||
</h3>
|
||||
<BarChart
|
||||
:data="{
|
||||
labels: (policyPerformance || []).map(p => p.policy_name),
|
||||
datasets: [
|
||||
{
|
||||
label: t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.SUCCESS_RATE'),
|
||||
data: (policyPerformance || []).map(p => p.success_rate),
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.8)',
|
||||
},
|
||||
],
|
||||
}"
|
||||
:height="300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table :columns="policyPerformanceColumns" :data="policyPerformance">
|
||||
<template #policy="{ row }">
|
||||
<span class="font-medium">{{ row.policy_name }}</span>
|
||||
</template>
|
||||
|
||||
<template #total_assignments="{ row }">
|
||||
{{ row.total_assignments }}
|
||||
</template>
|
||||
|
||||
<template #success_rate="{ row }">
|
||||
<span
|
||||
class="font-medium"
|
||||
:class="[
|
||||
row.success_rate >= 90
|
||||
? 'text-green-600'
|
||||
: row.success_rate >= 70
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600',
|
||||
]"
|
||||
>
|
||||
{{ row.success_rate }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #avg_handling_time="{ row }">
|
||||
{{ formatTime(row.avg_handling_time) }}
|
||||
</template>
|
||||
|
||||
<template #reassignment_rate="{ row }">
|
||||
{{ row.reassignment_rate }}
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Utilization Tab -->
|
||||
<div v-else-if="activeTab === 'utilization'" class="space-y-6">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
||||
<h3 class="text-lg font-medium mb-4">
|
||||
{{ $t('ASSIGNMENT_SETTINGS.METRICS.CHARTS.AGENT_UTILIZATION') }}
|
||||
</h3>
|
||||
<BarChart
|
||||
:data="getUtilizationChartData"
|
||||
:height="400"
|
||||
:options="{
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components/widgets/forms/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isEditMode = computed(() => !!route.params.id);
|
||||
const policyId = computed(() => route.params.id);
|
||||
|
||||
const uiFlags = computed(() => getters['assignmentPolicies/getUIFlags'].value);
|
||||
|
||||
const loading = ref(false);
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
assignment_order: 'round_robin',
|
||||
conversation_priority: 'earliest_created',
|
||||
fair_distribution_limit: 10,
|
||||
fair_distribution_window: 3600,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const assignmentOrderOptions = [
|
||||
{
|
||||
value: 'round_robin',
|
||||
label: 'Round Robin',
|
||||
description: 'Distribute conversations evenly among available agents',
|
||||
},
|
||||
{
|
||||
value: 'balanced',
|
||||
label: 'Balanced Assignment (Enterprise)',
|
||||
description: 'Assign to agents with the most available capacity',
|
||||
},
|
||||
];
|
||||
|
||||
const conversationPriorityOptions = [
|
||||
{
|
||||
value: 'earliest_created',
|
||||
label: 'Earliest Created',
|
||||
description: 'Assign conversations based on creation time',
|
||||
},
|
||||
{
|
||||
value: 'longest_waiting',
|
||||
label: 'Longest Waiting',
|
||||
description: 'Assign conversations that have been waiting the longest',
|
||||
},
|
||||
];
|
||||
|
||||
const errors = ref({});
|
||||
|
||||
const loadPolicy = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const policy = await store.dispatch(
|
||||
'assignmentPolicies/show',
|
||||
policyId.value
|
||||
);
|
||||
formData.value = {
|
||||
name: policy.name,
|
||||
description: policy.description || '',
|
||||
assignment_order: policy.assignment_order || 'round_robin',
|
||||
conversation_priority: policy.conversation_priority || 'earliest_created',
|
||||
fair_distribution_limit: policy.fair_distribution_limit || 10,
|
||||
fair_distribution_window: policy.fair_distribution_window || 3600,
|
||||
enabled: policy.enabled !== false,
|
||||
};
|
||||
} catch (error) {
|
||||
useAlert(t('ASSIGNMENT_SETTINGS.POLICIES.LOAD.ERROR'));
|
||||
router.push({ name: 'assignment_policies_list' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
errors.value = {};
|
||||
|
||||
if (!formData.value.name.trim()) {
|
||||
errors.value.name = t('ASSIGNMENT_SETTINGS.POLICIES.FORM.NAME_REQUIRED');
|
||||
}
|
||||
|
||||
if (!formData.value.assignment_order) {
|
||||
errors.value.assignment_order = 'Assignment order is required';
|
||||
}
|
||||
|
||||
if (!formData.value.conversation_priority) {
|
||||
errors.value.conversation_priority = 'Conversation priority is required';
|
||||
}
|
||||
|
||||
if (formData.value.fair_distribution_limit < 1) {
|
||||
errors.value.fair_distribution_limit =
|
||||
'Fair distribution limit must be at least 1';
|
||||
}
|
||||
|
||||
if (formData.value.fair_distribution_window < 60) {
|
||||
errors.value.fair_distribution_window =
|
||||
'Fair distribution window must be at least 60 seconds';
|
||||
}
|
||||
|
||||
return Object.keys(errors.value).length === 0;
|
||||
};
|
||||
|
||||
const savePolicy = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: formData.value.name.trim(),
|
||||
description: formData.value.description.trim(),
|
||||
assignment_order: formData.value.assignment_order,
|
||||
conversation_priority: formData.value.conversation_priority,
|
||||
fair_distribution_limit: formData.value.fair_distribution_limit,
|
||||
fair_distribution_window: formData.value.fair_distribution_window,
|
||||
enabled: formData.value.enabled,
|
||||
};
|
||||
|
||||
if (isEditMode.value) {
|
||||
await store.dispatch('assignmentPolicies/update', {
|
||||
id: policyId.value,
|
||||
...payload,
|
||||
});
|
||||
useAlert(t('ASSIGNMENT_SETTINGS.POLICIES.UPDATE.SUCCESS'));
|
||||
} else {
|
||||
await store.dispatch('assignmentPolicies/create', payload);
|
||||
useAlert(t('ASSIGNMENT_SETTINGS.POLICIES.CREATE.SUCCESS'));
|
||||
}
|
||||
|
||||
router.push({ name: 'assignment_policies_list' });
|
||||
} catch (error) {
|
||||
const message = isEditMode.value
|
||||
? t('ASSIGNMENT_SETTINGS.POLICIES.UPDATE.ERROR')
|
||||
: t('ASSIGNMENT_SETTINGS.POLICIES.CREATE.ERROR');
|
||||
useAlert(message);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
router.push({ name: 'assignment_policies_list' });
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEditMode.value) {
|
||||
await loadPolicy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
:title="
|
||||
isEditMode
|
||||
? $t('ASSIGNMENT_SETTINGS.POLICIES.EDIT_HEADER')
|
||||
: $t('ASSIGNMENT_SETTINGS.POLICIES.NEW_HEADER')
|
||||
"
|
||||
:description="$t('ASSIGNMENT_SETTINGS.POLICIES.FORM_DESCRIPTION')"
|
||||
:back-button-label="$t('ASSIGNMENT_SETTINGS.POLICIES.BACK_BUTTON')"
|
||||
@back="cancel"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center h-64">
|
||||
<Spinner :size="48" />
|
||||
</div>
|
||||
|
||||
<div v-else class="max-w-2xl p-8">
|
||||
<form @submit.prevent="savePolicy">
|
||||
<div class="space-y-6">
|
||||
<!-- Basic Information -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Basic Information</h3>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
v-model="formData.name"
|
||||
:label="$t('ASSIGNMENT_SETTINGS.POLICIES.FORM.NAME')"
|
||||
:placeholder="
|
||||
$t('ASSIGNMENT_SETTINGS.POLICIES.FORM.NAME_PLACEHOLDER')
|
||||
"
|
||||
:error="errors.name"
|
||||
required
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="formData.description"
|
||||
:label="$t('ASSIGNMENT_SETTINGS.POLICIES.FORM.DESCRIPTION')"
|
||||
:placeholder="
|
||||
$t(
|
||||
'ASSIGNMENT_SETTINGS.POLICIES.FORM.DESCRIPTION_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
rows="3"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch v-model="formData.enabled" />
|
||||
<label class="text-sm font-medium"> Policy Enabled </label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Order -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Assignment Order</h3>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="option in assignmentOrderOptions"
|
||||
:key="option.value"
|
||||
class="p-4 border rounded-lg cursor-pointer transition-colors"
|
||||
:class="[
|
||||
formData.assignment_order === option.value
|
||||
? 'border-woot-500 bg-woot-50'
|
||||
: 'border-slate-200 hover:border-slate-300',
|
||||
]"
|
||||
@click="formData.assignment_order = option.value"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="formData.assignment_order === option.value"
|
||||
class="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<h4 class="font-medium">{{ option.label }}</h4>
|
||||
<p class="text-sm text-slate-600 mt-1">
|
||||
{{ option.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="errors.assignment_order"
|
||||
class="text-red-500 text-sm mt-2"
|
||||
>
|
||||
{{ errors.assignment_order }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversation Priority -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Conversation Priority</h3>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="option in conversationPriorityOptions"
|
||||
:key="option.value"
|
||||
class="p-4 border rounded-lg cursor-pointer transition-colors"
|
||||
:class="[
|
||||
formData.conversation_priority === option.value
|
||||
? 'border-woot-500 bg-woot-50'
|
||||
: 'border-slate-200 hover:border-slate-300',
|
||||
]"
|
||||
@click="formData.conversation_priority = option.value"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="formData.conversation_priority === option.value"
|
||||
class="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<h4 class="font-medium">{{ option.label }}</h4>
|
||||
<p class="text-sm text-slate-600 mt-1">
|
||||
{{ option.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="errors.conversation_priority"
|
||||
class="text-red-500 text-sm mt-2"
|
||||
>
|
||||
{{ errors.conversation_priority }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fair Distribution Settings -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-sm border border-slate-200 p-6"
|
||||
>
|
||||
<h3 class="text-lg font-medium mb-4">Fair Distribution Settings</h3>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
v-model.number="formData.fair_distribution_limit"
|
||||
type="number"
|
||||
label="Fair Distribution Limit"
|
||||
placeholder="10"
|
||||
help-text="Maximum conversations to assign to an agent within the time window"
|
||||
:error="errors.fair_distribution_limit"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model.number="formData.fair_distribution_window"
|
||||
type="number"
|
||||
label="Fair Distribution Window (seconds)"
|
||||
placeholder="3600"
|
||||
help-text="Time window in seconds for fair distribution (default: 1 hour = 3600 seconds)"
|
||||
:error="errors.fair_distribution_window"
|
||||
min="60"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 mt-8">
|
||||
<Button variant="ghost" @click="cancel">
|
||||
{{ $t('COMMON.CANCEL') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
|
||||
>
|
||||
{{ isEditMode ? $t('COMMON.UPDATE') : $t('COMMON.CREATE') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, h } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
useVueTable,
|
||||
createColumnHelper,
|
||||
getCoreRowModel,
|
||||
} from '@tanstack/vue-table';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Table from 'dashboard/components/table/Table.vue';
|
||||
import ConfirmationModal from 'dashboard/components/widgets/modal/ConfirmationModal.vue';
|
||||
import Modal from 'dashboard/components/Modal.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||
import inboxesAPI from 'dashboard/api/inboxes';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const policies = computed(
|
||||
() => getters['assignmentPolicies/getPoliciesByPriority'].value
|
||||
);
|
||||
const uiFlags = computed(() => getters['assignmentPolicies/getUIFlags'].value);
|
||||
|
||||
const loading = ref({});
|
||||
const showDeletePopup = ref(false);
|
||||
const showAssignInboxModal = ref(false);
|
||||
const selectedPolicy = ref(null);
|
||||
const selectedInboxId = ref(null);
|
||||
const inboxes = computed(() => getters['inboxes/getInboxes'].value);
|
||||
|
||||
// Filter out already assigned inboxes
|
||||
const availableInboxes = computed(() => {
|
||||
if (!selectedPolicy.value) return inboxes.value;
|
||||
|
||||
const assignedInboxIds = selectedPolicy.value.inboxes?.map(i => i.id) || [];
|
||||
return inboxes.value.filter(inbox => !assignedInboxIds.includes(inbox.id));
|
||||
});
|
||||
|
||||
// Column helper for TanStack Table
|
||||
const columnHelper = createColumnHelper();
|
||||
const columns = [
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Policy Name',
|
||||
cell: info =>
|
||||
h('div', { class: 'font-medium text-slate-900' }, info.getValue()),
|
||||
size: 250,
|
||||
}),
|
||||
columnHelper.accessor('description', {
|
||||
header: 'Description',
|
||||
cell: info =>
|
||||
h('div', { class: 'text-sm text-slate-600' }, info.getValue() || '-'),
|
||||
size: 350,
|
||||
}),
|
||||
columnHelper.accessor('assignment_order', {
|
||||
header: 'Assignment Order',
|
||||
cell: info =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'font-medium text-slate-900' },
|
||||
info.getValue() === 'round_robin' ? 'Round Robin' : 'Balanced'
|
||||
),
|
||||
size: 150,
|
||||
}),
|
||||
columnHelper.accessor('conversation_priority', {
|
||||
header: 'Conversation Priority',
|
||||
cell: info =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'text-sm text-slate-600' },
|
||||
info.getValue() === 'earliest_created'
|
||||
? 'Earliest Created'
|
||||
: 'Longest Waiting'
|
||||
),
|
||||
size: 150,
|
||||
}),
|
||||
columnHelper.accessor('enabled', {
|
||||
header: 'Status',
|
||||
cell: info =>
|
||||
h('div', { class: 'flex items-center gap-2' }, [
|
||||
h('div', {
|
||||
class: `w-2 h-2 rounded-full ${
|
||||
info.getValue() ? 'bg-green-500' : 'bg-slate-400'
|
||||
}`,
|
||||
}),
|
||||
h(
|
||||
'span',
|
||||
{ class: 'text-sm' },
|
||||
info.getValue() ? 'Active' : 'Inactive'
|
||||
),
|
||||
]),
|
||||
size: 120,
|
||||
}),
|
||||
columnHelper.accessor(row => row.inboxes || [], {
|
||||
id: 'inboxes',
|
||||
header: 'Assigned Inboxes',
|
||||
cell: info => {
|
||||
const inboxes = info.getValue();
|
||||
const policyId = info.row.original.id;
|
||||
|
||||
if (!inboxes || inboxes.length === 0) {
|
||||
return h(
|
||||
'span',
|
||||
{ class: 'text-sm text-slate-500' },
|
||||
'No inboxes assigned'
|
||||
);
|
||||
}
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex flex-wrap gap-1' },
|
||||
inboxes.map(inbox =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class:
|
||||
'inline-flex items-center gap-1 px-2 py-1 text-xs bg-woot-50 text-woot-700 rounded-full border border-woot-200 hover:bg-woot-100 transition-colors group',
|
||||
},
|
||||
[
|
||||
h('span', inbox.name),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class:
|
||||
'ml-1 -mr-1 p-0.5 rounded-full hover:bg-woot-200 transition-colors',
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
unassignInbox(policyId, inbox.id, inbox.name);
|
||||
},
|
||||
title: `Remove ${inbox.name}`,
|
||||
},
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
class: 'w-3 h-3',
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20',
|
||||
},
|
||||
h('path', {
|
||||
'fill-rule': 'evenodd',
|
||||
d: 'M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z',
|
||||
'clip-rule': 'evenodd',
|
||||
})
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
size: 250,
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: info =>
|
||||
h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
icon: 'edit',
|
||||
onClick: () => editPolicy(info.row.original),
|
||||
},
|
||||
() => 'Edit'
|
||||
),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
icon: 'mail-inbox-all',
|
||||
onClick: () => openAssignInboxModal(info.row.original),
|
||||
title: 'Assign policy to inbox',
|
||||
},
|
||||
() => 'Assign'
|
||||
),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
color: 'ruby',
|
||||
size: 'sm',
|
||||
icon: 'delete',
|
||||
isLoading: loading.value[info.row.original.id],
|
||||
onClick: () => openDeletePopup(info.row.original),
|
||||
},
|
||||
() => 'Delete'
|
||||
),
|
||||
]),
|
||||
size: 200,
|
||||
}),
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('assignmentPolicies/get');
|
||||
store.dispatch('inboxes/get');
|
||||
});
|
||||
|
||||
const navigateToNew = () => {
|
||||
router.push({ name: 'assignment_policies_new' });
|
||||
};
|
||||
|
||||
const editPolicy = policy => {
|
||||
router.push({
|
||||
name: 'assignment_policies_edit',
|
||||
params: { id: policy.id },
|
||||
});
|
||||
};
|
||||
|
||||
const openDeletePopup = policy => {
|
||||
selectedPolicy.value = policy;
|
||||
showDeletePopup.value = true;
|
||||
};
|
||||
|
||||
const closeDeletePopup = () => {
|
||||
selectedPolicy.value = null;
|
||||
showDeletePopup.value = false;
|
||||
};
|
||||
|
||||
const openAssignInboxModal = policy => {
|
||||
selectedPolicy.value = policy;
|
||||
selectedInboxId.value = null;
|
||||
showAssignInboxModal.value = true;
|
||||
};
|
||||
|
||||
const closeAssignInboxModal = () => {
|
||||
selectedPolicy.value = null;
|
||||
selectedInboxId.value = null;
|
||||
showAssignInboxModal.value = false;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedPolicy.value) return;
|
||||
|
||||
try {
|
||||
loading.value[selectedPolicy.value.id] = true;
|
||||
await store.dispatch('assignmentPolicies/delete', selectedPolicy.value.id);
|
||||
useAlert('Assignment policy deleted successfully');
|
||||
closeDeletePopup();
|
||||
} catch (error) {
|
||||
useAlert('Failed to delete assignment policy');
|
||||
} finally {
|
||||
loading.value[selectedPolicy.value.id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const assignPolicyToInbox = async () => {
|
||||
if (!selectedPolicy.value || !selectedInboxId.value) return;
|
||||
|
||||
try {
|
||||
loading.value[selectedPolicy.value.id] = true;
|
||||
|
||||
// Use the inboxes API service
|
||||
await inboxesAPI.assignPolicy(
|
||||
selectedInboxId.value,
|
||||
selectedPolicy.value.id
|
||||
);
|
||||
|
||||
useAlert('Policy assigned to inbox successfully');
|
||||
// Refresh the policies to show the updated inbox assignments
|
||||
await store.dispatch('assignmentPolicies/get');
|
||||
closeAssignInboxModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to assign policy:', error);
|
||||
useAlert(error.response?.data?.error || 'Failed to assign policy to inbox');
|
||||
} finally {
|
||||
loading.value[selectedPolicy.value.id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const unassignInbox = async (policyId, inboxId, inboxName) => {
|
||||
try {
|
||||
loading.value[policyId] = true;
|
||||
|
||||
// Call the API to remove the assignment
|
||||
await inboxesAPI.removePolicy(inboxId);
|
||||
|
||||
useAlert(`Policy unassigned from ${inboxName}`);
|
||||
// Refresh the policies to show the updated inbox assignments
|
||||
await store.dispatch('assignmentPolicies/get');
|
||||
} catch (error) {
|
||||
console.error('Failed to unassign policy:', error);
|
||||
useAlert(
|
||||
error.response?.data?.error || 'Failed to unassign policy from inbox'
|
||||
);
|
||||
} finally {
|
||||
loading.value[policyId] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Table instance
|
||||
const table = computed(() =>
|
||||
useVueTable({
|
||||
data: policies.value,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseSettingsHeader
|
||||
title="Assignment Policies"
|
||||
description="Create and manage policies to automatically assign conversations to agents based on various criteria"
|
||||
back-button-label="Back to Assignment"
|
||||
@back="$router.push({ name: 'assignment_index' })"
|
||||
>
|
||||
<template #actions>
|
||||
<Button variant="solid" icon="add" @click="navigateToNew">
|
||||
New Policy
|
||||
</Button>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
|
||||
<div class="p-8">
|
||||
<div
|
||||
v-if="uiFlags.isFetching"
|
||||
class="flex items-center justify-center h-64"
|
||||
>
|
||||
<Spinner :size="48" />
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="!policies.length"
|
||||
title="No Assignment Policies"
|
||||
message="Create your first assignment policy to automatically distribute conversations to your agents"
|
||||
>
|
||||
<Button variant="solid" icon="add" @click="navigateToNew">
|
||||
New Policy
|
||||
</Button>
|
||||
</EmptyState>
|
||||
|
||||
<div v-else>
|
||||
<Table :table="table" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
v-model:show="showDeletePopup"
|
||||
title="Delete Assignment Policy"
|
||||
:message="`Are you sure you want to delete the policy '${selectedPolicy?.name}'? This action cannot be undone.`"
|
||||
confirm-text="Delete"
|
||||
cancel-text="Cancel"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="closeDeletePopup"
|
||||
/>
|
||||
|
||||
<!-- Assign Inbox Modal -->
|
||||
<Modal
|
||||
v-model:show="showAssignInboxModal"
|
||||
:on-close="closeAssignInboxModal"
|
||||
>
|
||||
<div class="flex flex-col p-6 max-w-md">
|
||||
<h2 class="text-lg font-semibold mb-3">Assign Policy to Inbox</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div
|
||||
class="px-3 py-1 bg-slate-100 rounded-full text-sm font-medium text-slate-700"
|
||||
>
|
||||
{{ selectedPolicy?.name }}
|
||||
</div>
|
||||
<span class="text-sm text-slate-500">→</span>
|
||||
<span class="text-sm text-slate-600">Select inbox</span>
|
||||
</div>
|
||||
|
||||
<select
|
||||
v-model="selectedInboxId"
|
||||
class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-woot-500 focus:border-woot-500 text-sm"
|
||||
>
|
||||
<option value="">Choose an inbox...</option>
|
||||
<option
|
||||
v-for="inbox in availableInboxes"
|
||||
:key="inbox.id"
|
||||
:value="inbox.id"
|
||||
>
|
||||
{{ inbox.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<p
|
||||
v-if="availableInboxes.length === 0"
|
||||
class="mt-2 text-xs text-slate-500"
|
||||
>
|
||||
All inboxes are already assigned to this policy
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" @click="closeAssignInboxModal">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
:is-loading="loading[selectedPolicy?.id]"
|
||||
:disabled="!selectedInboxId || availableInboxes.length === 0"
|
||||
@click="assignPolicyToInbox"
|
||||
>
|
||||
Assign Inbox
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,6 +32,8 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['back']);
|
||||
|
||||
const helpURL = getHelpUrlForFeature(props.featureName);
|
||||
|
||||
const openInNewTab = url => {
|
||||
@@ -46,6 +48,7 @@ const openInNewTab = url => {
|
||||
v-if="backButtonLabel"
|
||||
compact
|
||||
:button-label="backButtonLabel"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -298,8 +298,8 @@ export default {
|
||||
this.inbox.allow_messages_after_resolved;
|
||||
this.continuityViaEmail = this.inbox.continuity_via_email;
|
||||
this.channelWebsiteUrl = this.inbox.website_url;
|
||||
this.channelWelcomeTitle = this.inbox.welcome_title;
|
||||
this.channelWelcomeTagline = this.inbox.welcome_tagline;
|
||||
this.channelWelcomeTitle = this.inbox.welcome_title || '';
|
||||
this.channelWelcomeTagline = this.inbox.welcome_tagline || '';
|
||||
this.selectedFeatureFlags = this.inbox.selected_feature_flags || [];
|
||||
this.replyTime = this.inbox.reply_time;
|
||||
this.locktoSingleConversation = this.inbox.lock_to_single_conversation;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import account from './account/account.routes';
|
||||
import agent from './agents/agent.routes';
|
||||
import agentBot from './agentBots/agentBot.routes';
|
||||
import assignment from './assignment/assignment.routes';
|
||||
import attributes from './attributes/attributes.routes';
|
||||
import automation from './automation/automation.routes';
|
||||
import auditlogs from './auditlogs/audit.routes';
|
||||
@@ -45,6 +46,7 @@ export default {
|
||||
...account.routes,
|
||||
...agent.routes,
|
||||
...agentBot.routes,
|
||||
...assignment.routes,
|
||||
...attributes.routes,
|
||||
...automation.routes,
|
||||
...auditlogs.routes,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AssignmentV2::AssignmentJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(inbox_id: nil, conversation_id: nil)
|
||||
if conversation_id
|
||||
assign_single_conversation(conversation_id)
|
||||
elsif inbox_id
|
||||
assign_inbox_conversations(inbox_id)
|
||||
else
|
||||
Rails.logger.error 'AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_single_conversation(conversation_id)
|
||||
conversation = Conversation.find_by(id: conversation_id)
|
||||
return unless conversation
|
||||
|
||||
service = AssignmentV2::AssignmentService.new(inbox: conversation.inbox)
|
||||
service.perform_for_conversation(conversation)
|
||||
end
|
||||
|
||||
def assign_inbox_conversations(inbox_id)
|
||||
inbox = Inbox.find_by(id: inbox_id)
|
||||
return unless inbox
|
||||
return unless inbox.assignment_v2_enabled?
|
||||
|
||||
service = AssignmentV2::AssignmentService.new(inbox: inbox)
|
||||
assigned_count = service.perform_bulk_assignment
|
||||
|
||||
Rails.logger.info "AssignmentV2::AssignmentJob: Assigned #{assigned_count} conversations for inbox #{inbox_id}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ReassignConversationsJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account_user)
|
||||
return unless account_user
|
||||
|
||||
user = account_user.user
|
||||
account = account_user.account
|
||||
|
||||
# Find all open conversations assigned to this user
|
||||
conversations = account.conversations
|
||||
.open
|
||||
.where(assignee: user)
|
||||
|
||||
Rails.logger.info "Reassigning #{conversations.count} conversations for user #{user.name} (#{user.id}) on leave"
|
||||
|
||||
conversations.find_each do |conversation|
|
||||
reassign_conversation(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reassign_conversation(conversation)
|
||||
inbox = conversation.inbox
|
||||
|
||||
# Use Assignment V2 if enabled
|
||||
if inbox.assignment_v2_enabled?
|
||||
assignment_service = AssignmentV2::AssignmentService.new(inbox: inbox)
|
||||
|
||||
# Mark conversation as unassigned first
|
||||
conversation.update!(assignee: nil)
|
||||
|
||||
# Let the assignment service handle it
|
||||
assignment_service.perform_for_conversation(conversation)
|
||||
else
|
||||
# Fallback to auto assignment
|
||||
AutoAssignmentService.new(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: inbox.assignable_agents.map(&:id) - [conversation.assignee_id]
|
||||
).perform
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to reassign conversation #{conversation.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -28,6 +28,7 @@ class Account < ApplicationRecord
|
||||
include Reportable
|
||||
include Featurable
|
||||
include CacheKeys
|
||||
include AssignmentV2FeatureFlag
|
||||
|
||||
SETTINGS_PARAMS_SCHEMA = {
|
||||
'type': 'object',
|
||||
@@ -97,6 +98,11 @@ class Account < ApplicationRecord
|
||||
has_many :webhooks, dependent: :destroy_async
|
||||
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
|
||||
has_many :working_hours, dependent: :destroy_async
|
||||
has_many :leaves, dependent: :destroy_async, class_name: 'Leave'
|
||||
|
||||
# Assignment V2 associations
|
||||
has_many :assignment_policies, dependent: :destroy_async
|
||||
has_many :agent_capacity_policies, dependent: :destroy_async, class_name: 'Enterprise::AgentCapacityPolicy' if ChatwootApp.enterprise?
|
||||
|
||||
has_one_attached :contacts_export
|
||||
|
||||
|
||||
+20
-15
@@ -2,24 +2,26 @@
|
||||
#
|
||||
# Table name: account_users
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# active_at :datetime
|
||||
# auto_offline :boolean default(TRUE), not null
|
||||
# availability :integer default("online"), not null
|
||||
# role :integer default("agent")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# custom_role_id :bigint
|
||||
# inviter_id :bigint
|
||||
# user_id :bigint
|
||||
# id :bigint not null, primary key
|
||||
# active_at :datetime
|
||||
# auto_offline :boolean default(TRUE), not null
|
||||
# availability :integer default("online"), not null
|
||||
# role :integer default("agent")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# agent_capacity_policy_id :bigint
|
||||
# custom_role_id :bigint
|
||||
# inviter_id :bigint
|
||||
# user_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_account_users_on_account_id (account_id)
|
||||
# index_account_users_on_custom_role_id (custom_role_id)
|
||||
# index_account_users_on_user_id (user_id)
|
||||
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
|
||||
# index_account_users_on_account_id (account_id)
|
||||
# index_account_users_on_agent_capacity_policy_id (agent_capacity_policy_id)
|
||||
# index_account_users_on_custom_role_id (custom_role_id)
|
||||
# index_account_users_on_user_id (user_id)
|
||||
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
|
||||
#
|
||||
|
||||
class AccountUser < ApplicationRecord
|
||||
@@ -28,6 +30,9 @@ class AccountUser < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :user
|
||||
belongs_to :inviter, class_name: 'User', optional: true
|
||||
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy', optional: true
|
||||
|
||||
has_many :leaves, dependent: :destroy, class_name: 'Leave'
|
||||
|
||||
enum role: { agent: 0, administrator: 1 }
|
||||
enum availability: { online: 0, offline: 1, busy: 2 }
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: assignment_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# assignment_order :integer default("round_robin"), not null
|
||||
# conversation_priority :integer default("earliest_created"), not null
|
||||
# description :text
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# fair_distribution_limit :integer default(10), not null
|
||||
# fair_distribution_window :integer default(3600), not null
|
||||
# name :string(255) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_assignment_policies_on_account_id (account_id)
|
||||
# index_assignment_policies_on_enabled (enabled)
|
||||
# unique_assignment_policy_name_per_account (account_id,name) UNIQUE
|
||||
#
|
||||
|
||||
class AssignmentPolicy < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
# Enums
|
||||
enum assignment_order: { round_robin: 0, balanced: 1 }
|
||||
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
|
||||
|
||||
# Associations
|
||||
belongs_to :account
|
||||
has_many :inbox_assignment_policies, dependent: :destroy
|
||||
has_many :inboxes, through: :inbox_assignment_policies
|
||||
|
||||
# Validations
|
||||
validates :name, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :name, length: { maximum: 255 }
|
||||
validates :description, length: { maximum: 1000 }
|
||||
validates :fair_distribution_limit, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
|
||||
validates :fair_distribution_window, presence: true, numericality: { greater_than: 60, less_than_or_equal_to: 86_400 }
|
||||
validates :assignment_order, inclusion: { in: assignment_orders.keys }
|
||||
validates :conversation_priority, inclusion: { in: conversation_priorities.keys }
|
||||
|
||||
# Validate balanced assignment is only available for enterprise
|
||||
validate :validate_balanced_assignment_enterprise_only
|
||||
|
||||
# Server-side validation to prevent bypass
|
||||
before_save :enforce_enterprise_features
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
scope :disabled, -> { where(enabled: false) }
|
||||
|
||||
# Callbacks
|
||||
after_update_commit :clear_assignment_caches
|
||||
after_destroy :clear_assignment_caches
|
||||
|
||||
def can_use_balanced_assignment?
|
||||
account.feature_enabled?(:enterprise_agent_capacity) if account.respond_to?(:feature_enabled?)
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
assignment_order: assignment_order,
|
||||
conversation_priority: conversation_priority,
|
||||
fair_distribution_limit: fair_distribution_limit,
|
||||
fair_distribution_window: fair_distribution_window,
|
||||
enabled: enabled
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_balanced_assignment_enterprise_only
|
||||
return unless balanced?
|
||||
return if can_use_balanced_assignment?
|
||||
|
||||
errors.add(:assignment_order, 'Balanced assignment is only available for enterprise accounts')
|
||||
end
|
||||
|
||||
def enforce_enterprise_features
|
||||
# Server-side enforcement to prevent API bypass
|
||||
return unless balanced? && !can_use_balanced_assignment?
|
||||
|
||||
# Force to round_robin if enterprise not available
|
||||
self.assignment_order = 'round_robin'
|
||||
Rails.logger.warn("Assignment V2: Forced assignment_order to round_robin for non-enterprise account #{account_id}")
|
||||
end
|
||||
|
||||
def clear_assignment_caches
|
||||
# Clear Redis caches when policy is updated
|
||||
Rails.cache.delete("assignment_v2:policy:#{id}")
|
||||
inboxes.find_each do |inbox|
|
||||
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AssignmentV2FeatureFlag
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def assignment_v2_enabled?
|
||||
# Check account-level feature flag
|
||||
return false unless feature_enabled_for_account?
|
||||
|
||||
# Check for any inbox-level overrides
|
||||
return false if inbox_level_override_disabled?
|
||||
|
||||
# Check system-wide killswitch
|
||||
!GlobalConfig.get('assignment_v2_disabled', false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def feature_enabled_for_account?
|
||||
config = GlobalConfig.get('assignment_v2')
|
||||
return false unless config&.dig('enabled')
|
||||
|
||||
# If no account allowlist, enable for all
|
||||
allowed_accounts = config['accounts']
|
||||
return true if allowed_accounts.blank?
|
||||
|
||||
# Check if account is in allowlist
|
||||
allowed_accounts.include?(id)
|
||||
end
|
||||
|
||||
def inbox_level_override_disabled?
|
||||
return false unless respond_to?(:id)
|
||||
|
||||
# Allow per-inbox disabling during migration
|
||||
GlobalConfig.get('assignment_v2_disabled_inboxes', []).include?(id)
|
||||
end
|
||||
end
|
||||
@@ -14,11 +14,18 @@ module AutoAssignmentHandler
|
||||
return unless conversation_status_changed_to_open?
|
||||
return unless should_run_auto_assignment?
|
||||
|
||||
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
|
||||
if inbox.assignment_v2_enabled?
|
||||
# Use Assignment V2 system
|
||||
AssignmentV2::AssignmentJob.perform_later(conversation_id: id)
|
||||
else
|
||||
# Use legacy assignment system
|
||||
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
|
||||
end
|
||||
end
|
||||
|
||||
def should_run_auto_assignment?
|
||||
return false unless inbox.enable_auto_assignment?
|
||||
# Check auto assignment is enabled (either legacy or v2)
|
||||
return false unless inbox.auto_assignment_enabled?
|
||||
|
||||
# run only if assignee is blank or doesn't have access to inbox
|
||||
assignee.blank? || inbox.members.exclude?(assignee)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxAgentAvailability
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def available_agents(options = {})
|
||||
options = { check_capacity: true }.merge(options)
|
||||
|
||||
# Get online agent IDs
|
||||
online_agent_ids = fetch_online_agent_ids
|
||||
return inbox_members.none if online_agent_ids.empty?
|
||||
|
||||
# Base query - only online agents
|
||||
scope = build_online_agents_scope(online_agent_ids)
|
||||
|
||||
# Apply filters
|
||||
apply_agent_filters(scope, options)
|
||||
end
|
||||
|
||||
def member_ids_with_assignment_capacity
|
||||
return member_ids unless assignment_v2_enabled? && enterprise_capacity_enabled?
|
||||
|
||||
available_agents(check_capacity: true).pluck(:user_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_online_agents_scope(online_agent_ids)
|
||||
inbox_members
|
||||
.joins(:user)
|
||||
.where(users: { id: online_agent_ids })
|
||||
.includes(:user)
|
||||
end
|
||||
|
||||
def apply_agent_filters(scope, options)
|
||||
# Exclude specific users if requested
|
||||
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
|
||||
|
||||
# Apply capacity filtering for enterprise accounts
|
||||
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
|
||||
|
||||
# Apply rate limiting if implemented
|
||||
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
|
||||
|
||||
# Exclude agents who are on leave
|
||||
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def fetch_online_agent_ids
|
||||
OnlineStatusTracker.get_available_users(account_id)
|
||||
.select { |_key, value| value.eql?('online') }
|
||||
.keys
|
||||
.map(&:to_i)
|
||||
end
|
||||
|
||||
def enterprise_capacity_enabled?
|
||||
defined?(Enterprise) &&
|
||||
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
|
||||
end
|
||||
|
||||
def filter_by_capacity(inbox_members_scope)
|
||||
return inbox_members_scope unless capacity_check_required?
|
||||
|
||||
assignment_counts = fetch_assignment_counts
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
agent_has_capacity?(inbox_member, assignment_counts)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_check_required?
|
||||
defined?(Enterprise::InboxCapacityLimit) &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
def fetch_assignment_counts
|
||||
conversations
|
||||
.where(status: :open)
|
||||
.where.not(assignee_id: nil)
|
||||
.group(:assignee_id)
|
||||
.count
|
||||
end
|
||||
|
||||
def agent_has_capacity?(inbox_member, assignment_counts)
|
||||
user = inbox_member.user
|
||||
account_user = account.account_users.find_by(user: user)
|
||||
|
||||
return true unless account_user&.agent_capacity_policy_id
|
||||
|
||||
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
|
||||
return true unless capacity_limit&.conversation_limit
|
||||
|
||||
current_count = assignment_counts[user.id] || 0
|
||||
current_count < capacity_limit.conversation_limit
|
||||
end
|
||||
|
||||
def fetch_capacity_limit(policy_id)
|
||||
Enterprise::InboxCapacityLimit
|
||||
.where(agent_capacity_policy_id: policy_id)
|
||||
.find_by(inbox_id: id)
|
||||
end
|
||||
|
||||
def filter_by_rate_limits(inbox_members_scope)
|
||||
# Filter out agents who have exceeded rate limits
|
||||
return inbox_members_scope unless assignment_policy&.enabled?
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
|
||||
rate_limiter.within_limits?
|
||||
end
|
||||
end
|
||||
|
||||
def filter_agents_on_leave(inbox_members_scope)
|
||||
return inbox_members_scope unless defined?(Enterprise::AgentLeave)
|
||||
|
||||
# Filter out agents who are currently on leave
|
||||
on_leave_user_ids = Enterprise::AgentLeave
|
||||
.active
|
||||
.where(account_id: account_id)
|
||||
.pluck(:user_id)
|
||||
|
||||
return inbox_members_scope if on_leave_user_ids.empty?
|
||||
|
||||
inbox_members_scope.where.not(user_id: on_leave_user_ids)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxChannelTypes
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def sms?
|
||||
channel_type == 'Channel::Sms'
|
||||
end
|
||||
|
||||
def facebook?
|
||||
channel_type == 'Channel::FacebookPage'
|
||||
end
|
||||
|
||||
def instagram?
|
||||
(facebook? || instagram_direct?) && channel.instagram_id.present?
|
||||
end
|
||||
|
||||
def instagram_direct?
|
||||
channel_type == 'Channel::Instagram'
|
||||
end
|
||||
|
||||
def web_widget?
|
||||
channel_type == 'Channel::WebWidget'
|
||||
end
|
||||
|
||||
def api?
|
||||
channel_type == 'Channel::Api'
|
||||
end
|
||||
|
||||
def email?
|
||||
channel_type == 'Channel::Email'
|
||||
end
|
||||
|
||||
def twilio?
|
||||
channel_type == 'Channel::TwilioSms'
|
||||
end
|
||||
|
||||
def twitter?
|
||||
channel_type == 'Channel::TwitterProfile'
|
||||
end
|
||||
|
||||
def whatsapp?
|
||||
channel_type == 'Channel::Whatsapp'
|
||||
end
|
||||
|
||||
def inbox_type
|
||||
channel.name
|
||||
end
|
||||
|
||||
def active_bot?
|
||||
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
|
||||
status: 'enabled').count.positive?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxNameSanitization
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_validation :sanitize_name
|
||||
end
|
||||
|
||||
# Sanitizes inbox name for balanced email provider compatibility
|
||||
# ALLOWS: /'._- and Unicode letters/numbers/emojis
|
||||
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
|
||||
def sanitized_name
|
||||
return default_name_for_blank_name if name.blank?
|
||||
|
||||
sanitized = apply_sanitization_rules(name)
|
||||
sanitized.blank? && email? ? display_name_from_email : sanitized
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sanitize_name
|
||||
self.name = default_name_for_blank_name if name.blank?
|
||||
self.name = apply_sanitization_rules(name) if name.present?
|
||||
end
|
||||
|
||||
def default_name_for_blank_name
|
||||
return channel.try(:bot_name) if web_widget?
|
||||
|
||||
readable_name = display_name_from_email if email?
|
||||
readable_name ||= 'Inbox'
|
||||
"#{readable_name} #{SecureRandom.hex(4)}"
|
||||
end
|
||||
|
||||
def apply_sanitization_rules(name)
|
||||
name_without_special_characters = name.gsub(/[^a-zA-Z0-9\s]/, ' ')
|
||||
name_without_special_characters.gsub(/\s+/, ' ').strip
|
||||
end
|
||||
|
||||
def display_name_from_email
|
||||
channel.try(:imap_email)&.split('@')&.first&.capitalize
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxWebhooks
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name
|
||||
}
|
||||
end
|
||||
|
||||
def callback_webhook_url
|
||||
case channel_type
|
||||
when 'Channel::TwilioSms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
|
||||
when 'Channel::Sms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
|
||||
when 'Channel::Line'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
|
||||
when 'Channel::Whatsapp'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
|
||||
end
|
||||
end
|
||||
end
|
||||
+153
-86
@@ -44,6 +44,11 @@ class Inbox < ApplicationRecord
|
||||
include Avatarable
|
||||
include OutOfOffisable
|
||||
include AccountCacheRevalidator
|
||||
include AssignmentV2FeatureFlag
|
||||
include InboxAgentAvailability
|
||||
include InboxChannelTypes
|
||||
include InboxWebhooks
|
||||
include InboxNameSanitization
|
||||
|
||||
# Not allowing characters:
|
||||
validates :name, presence: true
|
||||
@@ -72,6 +77,10 @@ class Inbox < ApplicationRecord
|
||||
has_many :webhooks, dependent: :destroy_async
|
||||
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
|
||||
|
||||
# Assignment V2 associations
|
||||
has_one :inbox_assignment_policy, dependent: :destroy
|
||||
has_one :assignment_policy, through: :inbox_assignment_policy
|
||||
|
||||
enum sender_name_type: { friendly: 0, professional: 1 }
|
||||
|
||||
after_destroy :delete_round_robin_agents
|
||||
@@ -97,109 +106,167 @@ class Inbox < ApplicationRecord
|
||||
update_account_cache
|
||||
end
|
||||
|
||||
# Sanitizes inbox name for balanced email provider compatibility
|
||||
# ALLOWS: /'._- and Unicode letters/numbers/emojis
|
||||
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
|
||||
def sanitized_name
|
||||
return default_name_for_blank_name if name.blank?
|
||||
|
||||
sanitized = apply_sanitization_rules(name)
|
||||
sanitized.blank? && email? ? display_name_from_email : sanitized
|
||||
end
|
||||
|
||||
def sms?
|
||||
channel_type == 'Channel::Sms'
|
||||
end
|
||||
|
||||
def facebook?
|
||||
channel_type == 'Channel::FacebookPage'
|
||||
end
|
||||
|
||||
def instagram?
|
||||
(facebook? || instagram_direct?) && channel.instagram_id.present?
|
||||
end
|
||||
|
||||
def instagram_direct?
|
||||
channel_type == 'Channel::Instagram'
|
||||
end
|
||||
|
||||
def web_widget?
|
||||
channel_type == 'Channel::WebWidget'
|
||||
end
|
||||
|
||||
def api?
|
||||
channel_type == 'Channel::Api'
|
||||
end
|
||||
|
||||
def email?
|
||||
channel_type == 'Channel::Email'
|
||||
end
|
||||
|
||||
def twilio?
|
||||
channel_type == 'Channel::TwilioSms'
|
||||
end
|
||||
|
||||
def twitter?
|
||||
channel_type == 'Channel::TwitterProfile'
|
||||
end
|
||||
|
||||
def whatsapp?
|
||||
channel_type == 'Channel::Whatsapp'
|
||||
end
|
||||
|
||||
def assignable_agents
|
||||
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
|
||||
end
|
||||
|
||||
def active_bot?
|
||||
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
|
||||
status: 'enabled').count.positive?
|
||||
# Assignment V2 methods
|
||||
def assignment_v2_enabled?
|
||||
account.assignment_v2_enabled? && assignment_policy.present? && assignment_policy.enabled?
|
||||
end
|
||||
|
||||
def inbox_type
|
||||
channel.name
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name
|
||||
}
|
||||
end
|
||||
|
||||
def callback_webhook_url
|
||||
case channel_type
|
||||
when 'Channel::TwilioSms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
|
||||
when 'Channel::Sms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
|
||||
when 'Channel::Line'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
|
||||
when 'Channel::Whatsapp'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
|
||||
def auto_assignment_enabled?
|
||||
if assignment_v2_enabled?
|
||||
assignment_policy.present? && assignment_policy.enabled?
|
||||
else
|
||||
enable_auto_assignment?
|
||||
end
|
||||
end
|
||||
|
||||
def member_ids_with_assignment_capacity
|
||||
members.ids
|
||||
# Returns inbox members who are available for assignment
|
||||
# This method performs all filtering upfront at the database level for optimal performance
|
||||
#
|
||||
# Filters applied:
|
||||
# 1. Online status - Only agents marked as 'online' in OnlineStatusTracker
|
||||
# 2. Capacity limits (Enterprise) - Agents who haven't reached their conversation limit
|
||||
# 3. Rate limiting - Agents who haven't exceeded rate limits (when implemented)
|
||||
# 4. User exclusions - Specific users can be excluded (e.g., for reassignment)
|
||||
#
|
||||
# @param options [Hash] Additional filter options
|
||||
# @option options [Boolean] :check_capacity (true) Whether to check capacity limits
|
||||
# @option options [Boolean] :check_rate_limits (false) Whether to check rate limits
|
||||
# @option options [Array<Integer>] :exclude_user_ids Users to exclude from results
|
||||
#
|
||||
# @return [ActiveRecord::Relation<InboxMember>] Available inbox members with preloaded users
|
||||
#
|
||||
# @example Get all available agents
|
||||
# inbox.available_agents
|
||||
#
|
||||
# @example Get available agents excluding specific users
|
||||
# inbox.available_agents(exclude_user_ids: [1, 2, 3])
|
||||
#
|
||||
# @example Get available agents without capacity check (faster but less accurate)
|
||||
# inbox.available_agents(check_capacity: false)
|
||||
def available_agents(options = {})
|
||||
options = { check_capacity: true }.merge(options)
|
||||
|
||||
# Get online agent IDs
|
||||
online_agent_ids = fetch_online_agent_ids
|
||||
return inbox_members.none if online_agent_ids.empty?
|
||||
|
||||
# Base query - only online agents
|
||||
scope = build_online_agents_scope(online_agent_ids)
|
||||
|
||||
# Apply filters
|
||||
apply_agent_filters(scope, options)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_name_for_blank_name
|
||||
email? ? display_name_from_email : ''
|
||||
def build_online_agents_scope(online_agent_ids)
|
||||
inbox_members
|
||||
.joins(:user)
|
||||
.where(users: { id: online_agent_ids })
|
||||
.includes(:user)
|
||||
end
|
||||
|
||||
def apply_sanitization_rules(name)
|
||||
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
|
||||
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
|
||||
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
|
||||
.gsub(/\s+/, ' ') # Normalize spaces
|
||||
.strip
|
||||
def apply_agent_filters(scope, options)
|
||||
# Exclude specific users if requested
|
||||
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
|
||||
|
||||
# Apply capacity filtering for enterprise accounts
|
||||
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
|
||||
|
||||
# Apply rate limiting if implemented
|
||||
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
|
||||
|
||||
# Exclude agents who are on leave
|
||||
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def display_name_from_email
|
||||
channel.email.split('@').first.parameterize.titleize
|
||||
def fetch_online_agent_ids
|
||||
OnlineStatusTracker.get_available_users(account_id)
|
||||
.select { |_key, value| value.eql?('online') }
|
||||
.keys
|
||||
.map(&:to_i)
|
||||
end
|
||||
|
||||
def enterprise_capacity_enabled?
|
||||
defined?(Enterprise) &&
|
||||
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
|
||||
end
|
||||
|
||||
def filter_by_capacity(inbox_members_scope)
|
||||
return inbox_members_scope unless capacity_check_required?
|
||||
|
||||
assignment_counts = fetch_assignment_counts
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
agent_has_capacity?(inbox_member, assignment_counts)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_check_required?
|
||||
defined?(Enterprise::InboxCapacityLimit) &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
def fetch_assignment_counts
|
||||
conversations
|
||||
.where(status: :open)
|
||||
.where.not(assignee_id: nil)
|
||||
.group(:assignee_id)
|
||||
.count
|
||||
end
|
||||
|
||||
def agent_has_capacity?(inbox_member, assignment_counts)
|
||||
user = inbox_member.user
|
||||
account_user = account.account_users.find_by(user: user)
|
||||
|
||||
return true unless account_user&.agent_capacity_policy_id
|
||||
|
||||
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
|
||||
return true unless capacity_limit&.conversation_limit
|
||||
|
||||
current_count = assignment_counts[user.id] || 0
|
||||
current_count < capacity_limit.conversation_limit
|
||||
end
|
||||
|
||||
def fetch_capacity_limit(policy_id)
|
||||
Enterprise::InboxCapacityLimit
|
||||
.where(agent_capacity_policy_id: policy_id)
|
||||
.find_by(inbox_id: id)
|
||||
end
|
||||
|
||||
def filter_by_rate_limits(inbox_members_scope)
|
||||
# Filter out agents who have exceeded rate limits
|
||||
return inbox_members_scope unless assignment_policy&.enabled?
|
||||
|
||||
# Get IDs of inbox members within rate limits
|
||||
valid_inbox_member_ids = inbox_members_scope.find_each.filter_map do |inbox_member|
|
||||
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
|
||||
inbox_member.id if rate_limiter.within_limits?
|
||||
end
|
||||
|
||||
# Return filtered scope maintaining ActiveRecord relation
|
||||
inbox_members_scope.where(id: valid_inbox_member_ids)
|
||||
end
|
||||
|
||||
def filter_agents_on_leave(inbox_members_scope)
|
||||
# Get account users on active leave
|
||||
account_user_ids_on_leave = account.account_users
|
||||
.joins(:leaves)
|
||||
.where(leaves: { status: 'approved' })
|
||||
.where('leaves.start_date <= ? AND leaves.end_date >= ?', Date.current, Date.current)
|
||||
.pluck(:id)
|
||||
|
||||
return inbox_members_scope if account_user_ids_on_leave.empty?
|
||||
|
||||
# Exclude inbox members whose account_users are on leave
|
||||
user_ids_on_leave = account.account_users.where(id: account_user_ids_on_leave).pluck(:user_id)
|
||||
inbox_members_scope.where.not(user_id: user_ids_on_leave)
|
||||
end
|
||||
|
||||
def dispatch_create_event
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: inbox_assignment_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# assignment_policy_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
|
||||
# index_inbox_assignment_policies_on_inbox_id (inbox_id)
|
||||
# unique_inbox_assignment_policy (inbox_id) UNIQUE
|
||||
#
|
||||
|
||||
class InboxAssignmentPolicy < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
# Associations
|
||||
belongs_to :inbox
|
||||
belongs_to :assignment_policy
|
||||
|
||||
# Validations
|
||||
validates :inbox_id, uniqueness: true
|
||||
validate :inbox_belongs_to_same_account
|
||||
|
||||
# Delegations
|
||||
delegate :account, to: :inbox
|
||||
delegate :name, :description, :assignment_order, :conversation_priority,
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled?,
|
||||
to: :assignment_policy, prefix: :policy
|
||||
|
||||
# Callbacks
|
||||
after_commit :clear_inbox_cache
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: true }) }
|
||||
scope :disabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: false }) }
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
inbox_id: inbox_id,
|
||||
assignment_policy_id: assignment_policy_id,
|
||||
policy: assignment_policy.webhook_data
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox_belongs_to_same_account
|
||||
return unless inbox && assignment_policy
|
||||
|
||||
return if inbox.account_id == assignment_policy.account_id
|
||||
|
||||
errors.add(:inbox, 'must belong to the same account as the assignment policy')
|
||||
end
|
||||
|
||||
def clear_inbox_cache
|
||||
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox_id}")
|
||||
update_account_cache
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,108 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: leaves
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# approved_at :datetime
|
||||
# end_date :date not null
|
||||
# leave_type :integer default("vacation"), not null
|
||||
# reason :text
|
||||
# start_date :date not null
|
||||
# status :integer default("pending"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# account_user_id :bigint not null
|
||||
# approved_by_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_leaves_on_account_and_status (account_id,status)
|
||||
# index_leaves_on_account_id (account_id)
|
||||
# index_leaves_on_account_user_and_dates (account_user_id,start_date,end_date)
|
||||
# index_leaves_on_account_user_id (account_user_id)
|
||||
# index_leaves_on_approved_by_id (approved_by_id)
|
||||
# index_leaves_on_end_date (end_date)
|
||||
# index_leaves_on_start_date (start_date)
|
||||
# index_leaves_on_status (status)
|
||||
#
|
||||
|
||||
class Leave < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :account_user
|
||||
belongs_to :approved_by, class_name: 'User', optional: true
|
||||
|
||||
has_one :user, through: :account_user
|
||||
|
||||
enum leave_type: {
|
||||
vacation: 0,
|
||||
sick: 1,
|
||||
personal: 2,
|
||||
maternity: 3,
|
||||
paternity: 4,
|
||||
bereavement: 5,
|
||||
unpaid: 6
|
||||
}
|
||||
|
||||
enum status: {
|
||||
pending: 0,
|
||||
approved: 1,
|
||||
rejected: 2,
|
||||
cancelled: 3
|
||||
}
|
||||
|
||||
validates :start_date, presence: true
|
||||
validates :end_date, presence: true
|
||||
validates :leave_type, presence: true
|
||||
validates :status, presence: true
|
||||
validate :end_date_after_start_date
|
||||
validate :no_overlapping_leaves, if: :approved?
|
||||
|
||||
scope :active, -> { approved.where('start_date <= ? AND end_date >= ?', Date.current, Date.current) }
|
||||
scope :upcoming, -> { approved.where('start_date > ?', Date.current) }
|
||||
scope :past, -> { where('end_date < ?', Date.current) }
|
||||
scope :by_date_range, ->(start_date, end_date) { where('start_date <= ? AND end_date >= ?', end_date, start_date) }
|
||||
|
||||
before_update :set_approved_at, if: -> { status_changed? && approved? }
|
||||
|
||||
def active?
|
||||
approved? && start_date <= Date.current && end_date >= Date.current
|
||||
end
|
||||
|
||||
def days_count
|
||||
return 0 unless start_date && end_date
|
||||
|
||||
(end_date - start_date).to_i + 1
|
||||
end
|
||||
|
||||
def overlaps_with?(other_leave)
|
||||
return false if other_leave == self
|
||||
|
||||
start_date <= other_leave.end_date && end_date >= other_leave.start_date
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def end_date_after_start_date
|
||||
return unless start_date && end_date
|
||||
|
||||
errors.add(:end_date, 'must be after or equal to start date') if end_date < start_date
|
||||
end
|
||||
|
||||
def no_overlapping_leaves
|
||||
overlapping_leaves = account_user.leaves
|
||||
.approved
|
||||
.where.not(id: id)
|
||||
.by_date_range(start_date, end_date)
|
||||
|
||||
return unless overlapping_leaves.exists?
|
||||
|
||||
errors.add(:base, 'Leave dates overlap with an existing approved leave')
|
||||
end
|
||||
|
||||
def set_approved_at
|
||||
self.approved_at = Time.current
|
||||
end
|
||||
end
|
||||
@@ -100,6 +100,10 @@ class User < ApplicationRecord
|
||||
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
|
||||
# rubocop:enable Rails/HasManyOrHasOneDependent
|
||||
|
||||
# Assignment V2 Enterprise associations
|
||||
has_one :agent_capacity_policy_user, dependent: :destroy, class_name: 'Enterprise::AgentCapacityPolicyUser'
|
||||
has_one :agent_capacity_policy, through: :agent_capacity_policy_user, class_name: 'Enterprise::AgentCapacityPolicy'
|
||||
|
||||
before_validation :set_password_and_uid, on: :create
|
||||
after_destroy :remove_macros
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AgentCapacityPolicyPolicy < ApplicationPolicy
|
||||
def index?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).index?
|
||||
end
|
||||
|
||||
def show?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).show?
|
||||
end
|
||||
|
||||
def create?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).create?
|
||||
end
|
||||
|
||||
def update?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).update?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).destroy?
|
||||
end
|
||||
|
||||
def set_inbox_limit?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).set_inbox_limit?
|
||||
end
|
||||
|
||||
def remove_inbox_limit?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).remove_inbox_limit?
|
||||
end
|
||||
|
||||
def assign_user?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).assign_user?
|
||||
end
|
||||
|
||||
def remove_user?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).remove_user?
|
||||
end
|
||||
|
||||
def agent_capacity?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).agent_capacity?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AssignmentPolicyPolicy < ApplicationPolicy
|
||||
def index?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def show?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def create?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class LeavePolicy < ApplicationPolicy
|
||||
def index?
|
||||
true
|
||||
end
|
||||
|
||||
def show?
|
||||
# Users can view their own leaves or admins can view all
|
||||
record.account_user.user_id == user.id || @account_user.administrator?
|
||||
end
|
||||
|
||||
def create?
|
||||
# Users can create their own leave requests
|
||||
# When authorizing the class (not instance), allow any authenticated user
|
||||
return true if record.is_a?(Class)
|
||||
|
||||
record.account_user.user_id == user.id
|
||||
end
|
||||
|
||||
def update?
|
||||
# Users can update their own pending/rejected leaves
|
||||
# Admins can update any leave
|
||||
@account_user.administrator? || (record.account_user.user_id == user.id && record.pending?)
|
||||
end
|
||||
|
||||
def destroy?
|
||||
# Users can delete their own pending leaves
|
||||
# Admins can delete any non-approved leave
|
||||
if @account_user.administrator?
|
||||
!record.approved?
|
||||
else
|
||||
record.account_user.user_id == user.id && record.pending?
|
||||
end
|
||||
end
|
||||
|
||||
def approve?
|
||||
# Only admins can approve/reject leaves
|
||||
@account_user.administrator? && record.pending?
|
||||
end
|
||||
|
||||
def reject?
|
||||
approve?
|
||||
end
|
||||
|
||||
class Scope < ApplicationPolicy::Scope
|
||||
def resolve
|
||||
if @account_user.administrator?
|
||||
# Admins can see all leaves in the account
|
||||
scope.where(account: account)
|
||||
else
|
||||
# Regular users can only see their own leaves
|
||||
scope.joins(:account_user).where(account: account, account_users: { user_id: user.id })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AssignmentV2::AssignmentService
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def perform_for_conversation(conversation)
|
||||
return false unless can_assign?(conversation)
|
||||
|
||||
agent = find_agent_for_conversation(conversation)
|
||||
return false unless agent
|
||||
|
||||
assign_conversation_to_agent(conversation, agent)
|
||||
end
|
||||
|
||||
def perform_bulk_assignment(limit: 50)
|
||||
return 0 unless assignment_enabled?
|
||||
|
||||
conversations = unassigned_conversations(limit)
|
||||
assigned_count = 0
|
||||
|
||||
conversations.find_each do |conversation|
|
||||
assigned_count += 1 if perform_for_conversation(conversation)
|
||||
end
|
||||
|
||||
assigned_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def policy
|
||||
@policy ||= inbox.assignment_policy
|
||||
end
|
||||
|
||||
def assignment_enabled?
|
||||
policy&.enabled?
|
||||
end
|
||||
|
||||
def can_assign?(conversation)
|
||||
assignment_enabled? &&
|
||||
conversation.status == 'open' &&
|
||||
conversation.assignee_id.nil?
|
||||
end
|
||||
|
||||
def find_agent_for_conversation(_conversation)
|
||||
available_agents = inbox.available_agents(check_rate_limits: true)
|
||||
|
||||
if available_agents.empty?
|
||||
log_no_agents_available
|
||||
return nil
|
||||
end
|
||||
|
||||
selector_service.select_agent(available_agents)
|
||||
end
|
||||
|
||||
def selector_service
|
||||
@selector_service ||= if policy.assignment_order == 'balanced' && enterprise_enabled? && policy.can_use_balanced_assignment?
|
||||
::Enterprise::AssignmentV2::BalancedSelector.new(inbox: inbox)
|
||||
else
|
||||
AssignmentV2::RoundRobinSelector.new(inbox: inbox)
|
||||
end
|
||||
end
|
||||
|
||||
def unassigned_conversations(limit)
|
||||
scope = inbox.conversations
|
||||
.unassigned
|
||||
.open
|
||||
|
||||
# Apply conversation priority ordering
|
||||
scope = case policy.conversation_priority
|
||||
when 'longest_waiting'
|
||||
scope.order(last_activity_at: :asc, created_at: :asc)
|
||||
else
|
||||
scope.order(created_at: :asc)
|
||||
end
|
||||
|
||||
scope.limit(limit)
|
||||
end
|
||||
|
||||
def assign_conversation_to_agent(conversation, agent)
|
||||
conversation.update!(assignee: agent)
|
||||
create_assignment_activity(conversation, agent)
|
||||
record_assignment_in_rate_limiter(conversation, agent)
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "AssignmentV2: Failed to assign conversation #{conversation.id}: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def create_assignment_activity(conversation, agent)
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
Events::Types::ASSIGNEE_CHANGED,
|
||||
Time.zone.now,
|
||||
conversation: conversation,
|
||||
user: agent
|
||||
)
|
||||
end
|
||||
|
||||
def enterprise_enabled?
|
||||
@enterprise_enabled ||= defined?(Enterprise)
|
||||
end
|
||||
|
||||
def log_no_agents_available
|
||||
Rails.logger.warn("AssignmentV2: No agents available for inbox #{inbox.id}")
|
||||
end
|
||||
|
||||
def record_assignment_in_rate_limiter(conversation, agent)
|
||||
rate_limiter = AssignmentV2::RateLimiter.new(inbox: inbox, user: agent)
|
||||
rate_limiter.record_assignment(conversation)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "AssignmentV2: Failed to record assignment in rate limiter: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Rate limiter for assignment operations
|
||||
# Uses Redis to track assignment counts per agent per time window
|
||||
# based on assignment policy's fair_distribution_limit and fair_distribution_window
|
||||
class AssignmentV2::RateLimiter
|
||||
pattr_initialize [:inbox!, :user!]
|
||||
|
||||
# Check if the user has exceeded rate limits
|
||||
# @return [Boolean] true if within limits, false if exceeded
|
||||
def within_limits?
|
||||
return true unless policy_exists?
|
||||
|
||||
current_count < rate_limit
|
||||
end
|
||||
|
||||
# Record an assignment for rate limiting purposes
|
||||
# @param conversation [Conversation] The conversation being assigned
|
||||
def record_assignment(_conversation)
|
||||
return unless policy_exists?
|
||||
|
||||
key = rate_limit_key
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
redis.multi do |multi|
|
||||
multi.incr(key)
|
||||
multi.expire(key, time_window)
|
||||
end
|
||||
end
|
||||
|
||||
# Get current rate limit status for the user
|
||||
# @return [Hash] Rate limit status information
|
||||
def status
|
||||
if policy_exists?
|
||||
{
|
||||
within_limits: within_limits?,
|
||||
current_count: current_count,
|
||||
limit: rate_limit,
|
||||
reset_at: Time.zone.at(next_window_start)
|
||||
}
|
||||
else
|
||||
{
|
||||
within_limits: true,
|
||||
current_count: 0,
|
||||
limit: Float::INFINITY,
|
||||
reset_at: nil
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def policy
|
||||
@policy ||= inbox.assignment_policy
|
||||
end
|
||||
|
||||
def policy_exists?
|
||||
policy.present? && policy.enabled?
|
||||
end
|
||||
|
||||
def current_count
|
||||
key = rate_limit_key
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
redis.get(key).to_i
|
||||
end
|
||||
|
||||
def rate_limit
|
||||
policy&.fair_distribution_limit || 10
|
||||
end
|
||||
|
||||
def time_window
|
||||
policy&.fair_distribution_window || 3600
|
||||
end
|
||||
|
||||
def rate_limit_key
|
||||
"assignment_v2:rate_limit:#{user.id}:#{current_window}"
|
||||
end
|
||||
|
||||
def current_window
|
||||
(Time.current.to_i / time_window) * time_window
|
||||
end
|
||||
|
||||
def next_window_start
|
||||
current_window + time_window
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AssignmentV2::RoundRobinSelector
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def select_agent(available_agents)
|
||||
return nil if available_agents.empty?
|
||||
|
||||
# Extract user IDs from inbox members
|
||||
agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
|
||||
|
||||
# Use Redis queue for round robin
|
||||
selected_user_id = round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
|
||||
return nil unless selected_user_id
|
||||
|
||||
# Return the user object
|
||||
available_agents.find { |inbox_member| inbox_member.user_id.to_s == selected_user_id }&.user
|
||||
end
|
||||
|
||||
def add_agent_to_queue(user_id)
|
||||
round_robin_service.add_agent_to_queue(user_id)
|
||||
end
|
||||
|
||||
def remove_agent_from_queue(user_id)
|
||||
round_robin_service.remove_agent_from_queue(user_id)
|
||||
end
|
||||
|
||||
def reset_queue
|
||||
round_robin_service.reset_queue
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def round_robin_service
|
||||
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
|
||||
end
|
||||
end
|
||||
@@ -18,7 +18,9 @@ class AutoAssignment::InboxRoundRobinService
|
||||
|
||||
def reset_queue
|
||||
clear_queue
|
||||
add_agent_to_queue(inbox.inbox_members.map(&:user_id))
|
||||
inbox.inbox_members.each do |member|
|
||||
add_agent_to_queue(member.user_id)
|
||||
end
|
||||
end
|
||||
|
||||
# end of queue management functions
|
||||
@@ -27,8 +29,7 @@ class AutoAssignment::InboxRoundRobinService
|
||||
# the values of allowed member ids should be in string format
|
||||
def available_agent(allowed_agent_ids: [])
|
||||
reset_queue unless validate_queue?
|
||||
user_id = get_member_from_allowed_agent_ids(allowed_agent_ids)
|
||||
inbox.inbox_members.find_by(user_id: user_id)&.user if user_id.present?
|
||||
get_member_from_allowed_agent_ids(allowed_agent_ids)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -49,7 +50,7 @@ class AutoAssignment::InboxRoundRobinService
|
||||
end
|
||||
|
||||
def validate_queue?
|
||||
return true if inbox.inbox_members.map(&:user_id).sort == queue.map(&:to_i).sort
|
||||
inbox.inbox_members.map(&:user_id).map(&:to_s).sort == queue.sort
|
||||
end
|
||||
|
||||
def queue
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Leaves::LeaveApprovalService
|
||||
pattr_initialize [:leave!, :approver!]
|
||||
|
||||
def approve(comments = nil)
|
||||
return error_response('Leave is not pending') unless leave.pending?
|
||||
return error_response('You are not authorized to approve this leave') unless can_approve?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
leave.update!(
|
||||
status: 'approved',
|
||||
approved_by: approver,
|
||||
approved_at: Time.current
|
||||
)
|
||||
|
||||
create_approval_note(comments) if comments.present?
|
||||
notify_approval
|
||||
reassign_conversations_if_needed
|
||||
end
|
||||
|
||||
{ success: true, leave: leave }
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
error_response(e.record.errors.full_messages.join(', '))
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "LeaveApprovalService error: #{e.message}"
|
||||
error_response('An error occurred while approving the leave')
|
||||
end
|
||||
|
||||
def reject(reason)
|
||||
return error_response('Leave is not pending') unless leave.pending?
|
||||
return error_response('You are not authorized to reject this leave') unless can_approve?
|
||||
return error_response('Rejection reason is required') if reason.blank?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
leave.update!(
|
||||
status: 'rejected',
|
||||
approved_by: approver,
|
||||
approved_at: Time.current
|
||||
)
|
||||
|
||||
create_rejection_note(reason)
|
||||
notify_rejection
|
||||
end
|
||||
|
||||
{ success: true, leave: leave }
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
error_response(e.record.errors.full_messages.join(', '))
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "LeaveApprovalService error: #{e.message}"
|
||||
error_response('An error occurred while rejecting the leave')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_approve?
|
||||
account_user = leave.account.account_users.find_by(user: approver)
|
||||
account_user&.administrator?
|
||||
end
|
||||
|
||||
def error_response(message)
|
||||
{ success: false, errors: [message] }
|
||||
end
|
||||
|
||||
def create_approval_note(comments)
|
||||
# This would create a note/activity log if such system exists
|
||||
# For now, we'll just log it
|
||||
Rails.logger.info "Leave #{leave.id} approved by #{approver.name} with comments: #{comments}"
|
||||
end
|
||||
|
||||
def create_rejection_note(reason)
|
||||
# This would create a note/activity log if such system exists
|
||||
# For now, we'll just log it
|
||||
Rails.logger.info "Leave #{leave.id} rejected by #{approver.name} with reason: #{reason}"
|
||||
end
|
||||
|
||||
def notify_approval
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
LEAVE_APPROVED,
|
||||
Time.zone.now,
|
||||
leave: leave,
|
||||
approved_by: approver,
|
||||
account: leave.account,
|
||||
user: leave.user
|
||||
)
|
||||
end
|
||||
|
||||
def notify_rejection
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
LEAVE_REJECTED,
|
||||
Time.zone.now,
|
||||
leave: leave,
|
||||
rejected_by: approver,
|
||||
account: leave.account,
|
||||
user: leave.user
|
||||
)
|
||||
end
|
||||
|
||||
def reassign_conversations_if_needed
|
||||
# If the leave starts today or is already active, reassign conversations
|
||||
return unless leave.start_date <= Date.current
|
||||
|
||||
ReassignConversationsJob.perform_later(leave.account_user)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Leaves::LeaveService
|
||||
pattr_initialize [:account!, :account_user!, :current_user!]
|
||||
|
||||
def create(params)
|
||||
leave = account_user.leaves.build(filtered_params(params))
|
||||
leave.account = account
|
||||
|
||||
if leave.save
|
||||
notify_leave_creation(leave)
|
||||
{ success: true, leave: leave }
|
||||
else
|
||||
{ success: false, errors: leave.errors.full_messages }
|
||||
end
|
||||
end
|
||||
|
||||
def update(leave, params)
|
||||
if leave.update(filtered_params(params))
|
||||
notify_leave_update(leave)
|
||||
{ success: true, leave: leave }
|
||||
else
|
||||
{ success: false, errors: leave.errors.full_messages }
|
||||
end
|
||||
end
|
||||
|
||||
def cancel(leave)
|
||||
return { success: false, errors: ['Cannot cancel approved leave'] } if leave.approved?
|
||||
|
||||
if leave.update(status: 'cancelled')
|
||||
notify_leave_cancellation(leave)
|
||||
{ success: true, leave: leave }
|
||||
else
|
||||
{ success: false, errors: leave.errors.full_messages }
|
||||
end
|
||||
end
|
||||
|
||||
def list(filters = {})
|
||||
scope = policy_scope
|
||||
|
||||
# Apply filters
|
||||
scope = scope.where(status: filters[:status]) if filters[:status].present?
|
||||
scope = scope.where(leave_type: filters[:leave_type]) if filters[:leave_type].present?
|
||||
|
||||
scope = scope.by_date_range(filters[:start_date], filters[:end_date]) if filters[:start_date].present? && filters[:end_date].present?
|
||||
|
||||
scope = scope.joins(:account_user).where(account_users: { user_id: filters[:user_id] }) if filters[:user_id].present? && current_user_admin?
|
||||
|
||||
scope.includes(:account_user, :user, :approved_by).order(start_date: :desc)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filtered_params(params)
|
||||
allowed_params = [:start_date, :end_date, :leave_type, :reason]
|
||||
allowed_params << :status if current_user_admin?
|
||||
|
||||
params.slice(*allowed_params)
|
||||
end
|
||||
|
||||
def policy_scope
|
||||
Pundit.policy_scope(user_context, Leave)
|
||||
end
|
||||
|
||||
def user_context
|
||||
{
|
||||
user: current_user,
|
||||
account: account,
|
||||
account_user: account.account_users.find_by(user: current_user)
|
||||
}
|
||||
end
|
||||
|
||||
def current_user_admin?
|
||||
@current_user_admin ||= account.account_users.find_by(user: current_user)&.administrator?
|
||||
end
|
||||
|
||||
def notify_leave_creation(leave)
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
LEAVE_CREATED,
|
||||
Time.zone.now,
|
||||
leave: leave,
|
||||
account: account,
|
||||
user: leave.user
|
||||
)
|
||||
end
|
||||
|
||||
def notify_leave_update(leave)
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
LEAVE_UPDATED,
|
||||
Time.zone.now,
|
||||
leave: leave,
|
||||
account: account,
|
||||
user: leave.user
|
||||
)
|
||||
end
|
||||
|
||||
def notify_leave_cancellation(leave)
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
LEAVE_CANCELLED,
|
||||
Time.zone.now,
|
||||
leave: leave,
|
||||
account: account,
|
||||
user: leave.user
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,70 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Reports::AgentHistoryService
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params = {})
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def fetch_agent_assignment_history(agent)
|
||||
conversations = agent.assigned_conversations
|
||||
.includes(:inbox, :contact)
|
||||
.where(created_at: date_range)
|
||||
.order(created_at: :desc)
|
||||
.page(params[:page])
|
||||
.per(params[:per_page] || 50)
|
||||
|
||||
conversations.map do |conversation|
|
||||
{
|
||||
conversation_id: conversation.id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
inbox_name: conversation.inbox.name,
|
||||
contact_name: conversation.contact.name,
|
||||
assigned_at: conversation.assignee_last_seen_at || conversation.created_at,
|
||||
status: conversation.status,
|
||||
created_at: conversation.created_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def compute_all_agents_history
|
||||
agents = account.users.joins(:account_users).where(account_users: { role: %w[agent administrator] })
|
||||
|
||||
agents.map do |agent|
|
||||
conversations = agent.assigned_conversations.where(created_at: date_range)
|
||||
{
|
||||
agent: serialize_agent(agent),
|
||||
total_assignments: conversations.count,
|
||||
resolved_count: conversations.resolved.count,
|
||||
open_count: conversations.open.count,
|
||||
average_resolution_time: calculate_average_resolution_time(conversations.resolved)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def date_range
|
||||
start_date = params[:start_date] ? Date.parse(params[:start_date]).beginning_of_day : 30.days.ago
|
||||
end_date = params[:end_date] ? Date.parse(params[:end_date]).end_of_day : Time.current
|
||||
start_date..end_date
|
||||
end
|
||||
|
||||
def serialize_agent(agent)
|
||||
{
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
email: agent.email,
|
||||
avatar_url: agent.avatar_url
|
||||
}
|
||||
end
|
||||
|
||||
def calculate_average_resolution_time(conversations)
|
||||
return 0 if conversations.empty?
|
||||
|
||||
total_time = conversations.sum { |c| (c.last_activity_at - c.created_at) / 1.hour }
|
||||
(total_time / conversations.count).round(2)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'csv'
|
||||
|
||||
class Reports::AssignmentExportService
|
||||
attr_reader :data
|
||||
|
||||
def initialize(data)
|
||||
@data = data
|
||||
end
|
||||
|
||||
def generate_csv
|
||||
CSV.generate(headers: true) do |csv|
|
||||
add_header(csv)
|
||||
add_summary_metrics(csv)
|
||||
add_inbox_metrics(csv)
|
||||
add_agent_metrics(csv)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_header(csv)
|
||||
csv << ['Assignment Metrics Report']
|
||||
csv << ['Generated at', Time.current]
|
||||
csv << []
|
||||
end
|
||||
|
||||
def add_summary_metrics(csv)
|
||||
csv << ['Summary Metrics']
|
||||
csv << %w[Metric Value]
|
||||
data[:summary].each do |key, value|
|
||||
csv << [key.to_s.humanize, value]
|
||||
end
|
||||
csv << []
|
||||
end
|
||||
|
||||
def add_inbox_metrics(csv)
|
||||
csv << ['Inbox Metrics']
|
||||
csv << ['Inbox Name', 'Total Assignments', 'Average Assignment Time', 'Unique Agents']
|
||||
data[:by_inbox].each do |inbox|
|
||||
csv << [inbox[:inbox_name], inbox[:total_assignments], inbox[:average_assignment_time], inbox[:unique_agents]]
|
||||
end
|
||||
csv << []
|
||||
end
|
||||
|
||||
def add_agent_metrics(csv)
|
||||
csv << ['Agent Metrics']
|
||||
csv << ['Agent Name', 'Email', 'Assignment Count']
|
||||
data[:by_agent].each do |agent|
|
||||
csv << [agent[:agent_name], agent[:agent_email], agent[:assignment_count]]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,163 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Reports::AssignmentMetricsService
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account, params = {})
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def compute_assignment_metrics
|
||||
{
|
||||
total_assigned: total_assigned_conversations,
|
||||
assignment_rate: calculate_assignment_rate,
|
||||
average_response_time: calculate_average_response_time,
|
||||
average_resolution_time: calculate_average_resolution_time,
|
||||
assignments_by_policy: assignments_by_policy,
|
||||
period_metrics: compute_period_metrics
|
||||
}
|
||||
end
|
||||
|
||||
def compute_policy_performance(policy)
|
||||
conversations = policy.assignment_logs
|
||||
.joins(:conversation)
|
||||
.where(conversations: { created_at: date_range })
|
||||
|
||||
{
|
||||
policy_id: policy.id,
|
||||
policy_name: policy.name,
|
||||
total_assignments: conversations.count,
|
||||
average_assignment_time: calculate_average_time(conversations, :assignment_time),
|
||||
successful_assignments: conversations.where(success: true).count,
|
||||
failed_assignments: conversations.where(success: false).count
|
||||
}
|
||||
end
|
||||
|
||||
def compute_agent_utilization(agent)
|
||||
conversations = agent.assigned_conversations.where(created_at: date_range)
|
||||
capacity_limit = fetch_agent_capacity_limit(agent)
|
||||
|
||||
{
|
||||
agent_id: agent.id,
|
||||
agent_name: agent.name,
|
||||
current_load: agent.assigned_conversations.open.count,
|
||||
capacity_limit: capacity_limit,
|
||||
utilization_percentage: calculate_utilization_percentage(agent, capacity_limit),
|
||||
total_handled: conversations.count,
|
||||
average_handling_time: calculate_average_handling_time(conversations)
|
||||
}
|
||||
end
|
||||
|
||||
def compute_distribution_by_inbox
|
||||
Conversation.joins(:inbox)
|
||||
.where(created_at: date_range, account_id: account.id)
|
||||
.where.not(assignee_id: nil)
|
||||
.group('inboxes.name')
|
||||
.count
|
||||
end
|
||||
|
||||
def compute_distribution_by_team
|
||||
Conversation.joins(assignee: { team_members: :team })
|
||||
.where(created_at: date_range, account_id: account.id)
|
||||
.group('teams.name')
|
||||
.count
|
||||
end
|
||||
|
||||
def compute_distribution_by_hour
|
||||
Conversation.where(created_at: date_range, account_id: account.id)
|
||||
.where.not(assignee_id: nil)
|
||||
.group_by_hour(:created_at, format: '%H')
|
||||
.count
|
||||
end
|
||||
|
||||
def compute_period_metrics
|
||||
group_by = params[:group_by] || 'day'
|
||||
conversations = filter_conversations_by_date_range
|
||||
|
||||
case group_by
|
||||
when 'hour'
|
||||
group_by_hour(conversations)
|
||||
when 'week'
|
||||
group_by_week(conversations)
|
||||
when 'month'
|
||||
group_by_month(conversations)
|
||||
else
|
||||
group_by_day(conversations)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def date_range
|
||||
@date_range ||= params[:since]..params[:until]
|
||||
end
|
||||
|
||||
def filter_conversations_by_date_range
|
||||
Conversation.where(account_id: account.id, created_at: date_range)
|
||||
end
|
||||
|
||||
def total_assigned_conversations
|
||||
filter_conversations_by_date_range.where.not(assignee_id: nil).count
|
||||
end
|
||||
|
||||
def calculate_assignment_rate
|
||||
total = filter_conversations_by_date_range.count
|
||||
return 0.0 if total.zero?
|
||||
|
||||
(total_assigned_conversations.to_f / total * 100).round(2)
|
||||
end
|
||||
|
||||
def calculate_average_response_time
|
||||
# Implementation for average response time
|
||||
0
|
||||
end
|
||||
|
||||
def calculate_average_resolution_time
|
||||
# Implementation for average resolution time
|
||||
0
|
||||
end
|
||||
|
||||
def assignments_by_policy
|
||||
# Implementation for assignments by policy
|
||||
{}
|
||||
end
|
||||
|
||||
def fetch_agent_capacity_limit(_agent)
|
||||
# Implementation to fetch agent capacity limit
|
||||
nil
|
||||
end
|
||||
|
||||
def calculate_utilization_percentage(agent, capacity_limit)
|
||||
return 0.0 unless capacity_limit&.positive?
|
||||
|
||||
current_load = agent.assigned_conversations.open.count
|
||||
(current_load.to_f / capacity_limit * 100).round(2)
|
||||
end
|
||||
|
||||
def calculate_average_handling_time(_conversations)
|
||||
# Implementation for average handling time
|
||||
0
|
||||
end
|
||||
|
||||
def calculate_average_time(_conversations, _field)
|
||||
# Implementation for average time calculation
|
||||
0
|
||||
end
|
||||
|
||||
def group_by_hour(conversations)
|
||||
conversations.group_by_hour(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_day(conversations)
|
||||
conversations.group_by_day(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_week(conversations)
|
||||
conversations.group_by_week(:created_at).count
|
||||
end
|
||||
|
||||
def group_by_month(conversations)
|
||||
conversations.group_by_month(:created_at).count
|
||||
end
|
||||
end
|
||||
@@ -191,3 +191,7 @@
|
||||
display_name: CRM V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: enterprise_agent_capacity
|
||||
display_name: Enterprise Agent Capacity
|
||||
enabled: true
|
||||
premium: true
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Leave Management Events
|
||||
LEAVE_CREATED = 'leave.created'
|
||||
LEAVE_UPDATED = 'leave.updated'
|
||||
LEAVE_APPROVED = 'leave.approved'
|
||||
LEAVE_REJECTED = 'leave.rejected'
|
||||
LEAVE_CANCELLED = 'leave.cancelled'
|
||||
@@ -217,6 +217,46 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :leaves do
|
||||
member do
|
||||
post :approve
|
||||
post :reject
|
||||
end
|
||||
end
|
||||
|
||||
# Assignment V2 Routes
|
||||
resources :assignment_policies
|
||||
|
||||
resources :inboxes, only: [] do
|
||||
resource :assignment_policy, only: [:show, :create, :destroy], controller: 'inbox_assignment_policies'
|
||||
end
|
||||
|
||||
# Agent Capacity Management (Enterprise)
|
||||
resources :agent_capacity_policies do
|
||||
member do
|
||||
post 'inbox_limits/:inbox_id', to: 'agent_capacity_policies#set_inbox_limit'
|
||||
delete 'inbox_limits/:inbox_id', to: 'agent_capacity_policies#remove_inbox_limit'
|
||||
post 'users', to: 'agent_capacity_policies#assign_user'
|
||||
delete 'users/:user_id', to: 'agent_capacity_policies#remove_user'
|
||||
end
|
||||
end
|
||||
|
||||
# Agent capacity status
|
||||
get 'agents/:agent_id/capacity', to: 'agent_capacity_policies#agent_capacity'
|
||||
|
||||
# Assignment Metrics
|
||||
namespace :reports do
|
||||
resources :assignment_metrics, only: [:index] do
|
||||
collection do
|
||||
get 'agent_history'
|
||||
get 'policy_performance'
|
||||
get 'agent_utilization'
|
||||
get 'assignment_distribution'
|
||||
get 'export'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
namespace :twitter do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateAssignmentPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :assignment_policies do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.string :name, null: false, limit: 255
|
||||
t.text :description
|
||||
t.integer :assignment_order, null: false, default: 0 # 0: round_robin, 1: balanced
|
||||
t.integer :conversation_priority, null: false, default: 0 # 0: earliest_created, 1: longest_waiting
|
||||
t.integer :fair_distribution_limit, null: false, default: 10
|
||||
t.integer :fair_distribution_window, null: false, default: 3600 # seconds
|
||||
t.boolean :enabled, null: false, default: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :assignment_policies, [:account_id, :name], unique: true, name: 'unique_assignment_policy_name_per_account'
|
||||
add_index :assignment_policies, :enabled, name: 'index_assignment_policies_on_enabled'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateInboxAssignmentPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :inbox_assignment_policies do |t|
|
||||
t.references :inbox, null: false, index: true
|
||||
t.references :assignment_policy, null: false, index: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :inbox_assignment_policies, :inbox_id, unique: true, name: 'unique_inbox_assignment_policy'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateEnterpriseAgentCapacityPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :enterprise_agent_capacity_policies do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.string :name, null: false, limit: 255
|
||||
t.text :description
|
||||
t.jsonb :exclusion_rules, default: {}, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :enterprise_agent_capacity_policies, [:account_id, :name], unique: true, name: 'unique_capacity_policy_name_per_account'
|
||||
add_index :enterprise_agent_capacity_policies, :exclusion_rules, using: :gin, name: 'index_capacity_policies_on_exclusion_rules'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateEnterpriseInboxCapacityLimits < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :enterprise_inbox_capacity_limits do |t|
|
||||
t.references :agent_capacity_policy, null: false, index: { name: 'index_inbox_limits_on_capacity_policy' }
|
||||
t.references :inbox, null: false, index: true
|
||||
t.integer :conversation_limit, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :enterprise_inbox_capacity_limits, [:agent_capacity_policy_id, :inbox_id], unique: true, name: 'unique_policy_inbox_limit'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddAgentCapacityPolicyToAccountUsers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_reference :account_users, :agent_capacity_policy, null: true, index: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateLeaves < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :leaves do |t|
|
||||
t.references :account, null: false
|
||||
t.references :account_user, null: false
|
||||
t.date :start_date, null: false
|
||||
t.date :end_date, null: false
|
||||
t.integer :leave_type, null: false, default: 0
|
||||
t.integer :status, null: false, default: 0
|
||||
t.text :reason
|
||||
t.references :approved_by
|
||||
t.datetime :approved_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :leaves, :start_date
|
||||
add_index :leaves, :end_date
|
||||
add_index :leaves, :status
|
||||
add_index :leaves, [:account_user_id, :start_date, :end_date], name: 'index_leaves_on_account_user_and_dates'
|
||||
add_index :leaves, [:account_id, :status], name: 'index_leaves_on_account_and_status'
|
||||
end
|
||||
end
|
||||
+74
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_30_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -39,8 +39,10 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.integer "availability", default: 0, null: false
|
||||
t.boolean "auto_offline", default: true, null: false
|
||||
t.bigint "custom_role_id"
|
||||
t.bigint "agent_capacity_policy_id"
|
||||
t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true
|
||||
t.index ["account_id"], name: "index_account_users_on_account_id"
|
||||
t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id"
|
||||
t.index ["custom_role_id"], name: "index_account_users_on_custom_role_id"
|
||||
t.index ["user_id"], name: "index_account_users_on_user_id"
|
||||
end
|
||||
@@ -169,6 +171,22 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["views"], name: "index_articles_on_views"
|
||||
end
|
||||
|
||||
create_table "assignment_policies", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", limit: 255, null: false
|
||||
t.text "description"
|
||||
t.integer "assignment_order", default: 0, null: false
|
||||
t.integer "conversation_priority", default: 0, null: false
|
||||
t.integer "fair_distribution_limit", default: 10, null: false
|
||||
t.integer "fair_distribution_window", default: 3600, null: false
|
||||
t.boolean "enabled", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "name"], name: "unique_assignment_policy_name_per_account", unique: true
|
||||
t.index ["account_id"], name: "index_assignment_policies_on_account_id"
|
||||
t.index ["enabled"], name: "index_assignment_policies_on_enabled"
|
||||
end
|
||||
|
||||
create_table "attachments", id: :serial, force: :cascade do |t|
|
||||
t.integer "file_type", default: 0
|
||||
t.string "external_url"
|
||||
@@ -720,6 +738,29 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["name", "account_id"], name: "index_email_templates_on_name_and_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "enterprise_agent_capacity_policies", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", limit: 255, null: false
|
||||
t.text "description"
|
||||
t.jsonb "exclusion_rules", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "name"], name: "unique_capacity_policy_name_per_account", unique: true
|
||||
t.index ["account_id"], name: "index_enterprise_agent_capacity_policies_on_account_id"
|
||||
t.index ["exclusion_rules"], name: "index_capacity_policies_on_exclusion_rules", using: :gin
|
||||
end
|
||||
|
||||
create_table "enterprise_inbox_capacity_limits", force: :cascade do |t|
|
||||
t.bigint "agent_capacity_policy_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
t.integer "conversation_limit", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["agent_capacity_policy_id", "inbox_id"], name: "unique_policy_inbox_limit", unique: true
|
||||
t.index ["agent_capacity_policy_id"], name: "index_inbox_limits_on_capacity_policy"
|
||||
t.index ["inbox_id"], name: "index_enterprise_inbox_capacity_limits_on_inbox_id"
|
||||
end
|
||||
|
||||
create_table "folders", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.integer "category_id", null: false
|
||||
@@ -728,6 +769,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "inbox_assignment_policies", force: :cascade do |t|
|
||||
t.bigint "inbox_id", null: false
|
||||
t.bigint "assignment_policy_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["assignment_policy_id"], name: "index_inbox_assignment_policies_on_assignment_policy_id"
|
||||
t.index ["inbox_id"], name: "index_inbox_assignment_policies_on_inbox_id"
|
||||
t.index ["inbox_id"], name: "unique_inbox_assignment_policy", unique: true
|
||||
end
|
||||
|
||||
create_table "inbox_members", id: :serial, force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.integer "inbox_id", null: false
|
||||
@@ -800,6 +851,28 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "leaves", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "account_user_id", null: false
|
||||
t.date "start_date", null: false
|
||||
t.date "end_date", null: false
|
||||
t.integer "leave_type", default: 0, null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.text "reason"
|
||||
t.bigint "approved_by_id"
|
||||
t.datetime "approved_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "status"], name: "index_leaves_on_account_and_status"
|
||||
t.index ["account_id"], name: "index_leaves_on_account_id"
|
||||
t.index ["account_user_id", "start_date", "end_date"], name: "index_leaves_on_account_user_and_dates"
|
||||
t.index ["account_user_id"], name: "index_leaves_on_account_user_id"
|
||||
t.index ["approved_by_id"], name: "index_leaves_on_approved_by_id"
|
||||
t.index ["end_date"], name: "index_leaves_on_end_date"
|
||||
t.index ["start_date"], name: "index_leaves_on_start_date"
|
||||
t.index ["status"], name: "index_leaves_on_status"
|
||||
end
|
||||
|
||||
create_table "macros", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", null: false
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: enterprise_agent_capacity_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# account_id :bigint not null
|
||||
# name :string(255) not null
|
||||
# description :text
|
||||
# exclusion_rules :jsonb default: {}
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_enterprise_agent_capacity_policies_on_account_id (account_id)
|
||||
# unique_capacity_policy_name_per_account (account_id,name) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
#
|
||||
|
||||
class Enterprise::AgentCapacityPolicy < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
self.table_name = 'enterprise_agent_capacity_policies'
|
||||
|
||||
# Associations
|
||||
belongs_to :account, class_name: '::Account'
|
||||
has_many :account_users, class_name: '::AccountUser', dependent: :nullify
|
||||
has_many :users, through: :account_users, source: :user, class_name: '::User'
|
||||
has_many :inbox_capacity_limits, dependent: :destroy, class_name: 'Enterprise::InboxCapacityLimit'
|
||||
has_many :inboxes, through: :inbox_capacity_limits
|
||||
|
||||
# Validations
|
||||
validates :name, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :name, length: { maximum: 255 }
|
||||
validates :description, length: { maximum: 1000 }
|
||||
validate :validate_exclusion_rules_schema
|
||||
|
||||
# Callbacks
|
||||
before_save :validate_inbox_access
|
||||
after_update_commit :invalidate_capacity_caches
|
||||
after_destroy :invalidate_capacity_caches
|
||||
|
||||
# Scopes
|
||||
scope :with_users, -> { joins(:account_users) }
|
||||
scope :for_inbox, ->(inbox) { joins(:inbox_capacity_limits).where(enterprise_inbox_capacity_limits: { inbox: inbox }) }
|
||||
|
||||
def add_user(user)
|
||||
# Find the account_user for this account and user
|
||||
account_user = account.account_users.find_by!(user: user)
|
||||
|
||||
# Update the capacity policy reference
|
||||
account_user.update!(agent_capacity_policy_id: id)
|
||||
invalidate_user_capacity_cache(user)
|
||||
end
|
||||
|
||||
def remove_user(user)
|
||||
account_user = account.account_users.find_by(user: user, agent_capacity_policy_id: id)
|
||||
account_user&.update!(agent_capacity_policy_id: nil)
|
||||
invalidate_user_capacity_cache(user)
|
||||
end
|
||||
|
||||
def set_inbox_limit(inbox, limit)
|
||||
inbox_capacity_limit = inbox_capacity_limits.find_or_initialize_by(inbox: inbox)
|
||||
inbox_capacity_limit.conversation_limit = limit
|
||||
inbox_capacity_limit.save!
|
||||
|
||||
invalidate_inbox_capacity_cache(inbox)
|
||||
end
|
||||
|
||||
def remove_inbox_limit(inbox)
|
||||
inbox_capacity_limits.where(inbox: inbox).destroy_all
|
||||
invalidate_inbox_capacity_cache(inbox)
|
||||
end
|
||||
|
||||
def get_inbox_limit(inbox)
|
||||
inbox_capacity_limits.find_by(inbox: inbox)&.conversation_limit
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
exclusion_rules: exclusion_rules,
|
||||
users_count: users.count,
|
||||
inboxes_count: inboxes.count
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_exclusion_rules_schema
|
||||
return if exclusion_rules.blank?
|
||||
|
||||
schema = self.class.exclusion_rules_schema
|
||||
schemer = JSONSchemer.schema(schema)
|
||||
validation_errors = schemer.validate(exclusion_rules)
|
||||
|
||||
validation_errors.each do |error|
|
||||
errors.add(:exclusion_rules, error['error'])
|
||||
end
|
||||
end
|
||||
|
||||
def validate_inbox_access
|
||||
# Ensure all specified inboxes belong to the same account
|
||||
invalid_inboxes = inbox_capacity_limits.joins(:inbox)
|
||||
.where.not(inboxes: { account_id: account_id })
|
||||
|
||||
return unless invalid_inboxes.exists?
|
||||
|
||||
errors.add(:inbox_capacity_limits, 'contains inboxes from different accounts')
|
||||
throw :abort
|
||||
end
|
||||
|
||||
def invalidate_capacity_caches
|
||||
users.find_each { |user| invalidate_user_capacity_cache(user) }
|
||||
inboxes.find_each { |inbox| invalidate_inbox_capacity_cache(inbox) }
|
||||
end
|
||||
|
||||
def invalidate_user_capacity_cache(user)
|
||||
Rails.cache.delete_matched("assignment_v2:capacity:#{user.id}:*")
|
||||
end
|
||||
|
||||
def invalidate_inbox_capacity_cache(inbox)
|
||||
Rails.cache.delete_matched("assignment_v2:capacity:*:#{inbox.id}")
|
||||
end
|
||||
|
||||
class << self
|
||||
def exclusion_rules_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
labels: { type: 'array', items: { type: 'string' } },
|
||||
hours_threshold: { type: 'integer', minimum: 1, maximum: 168 }
|
||||
},
|
||||
additionalProperties: false
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: enterprise_agent_capacity_policy_users
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# agent_capacity_policy_id :bigint not null
|
||||
# user_id :bigint not null
|
||||
# created_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# unique_user_capacity_policy (user_id) UNIQUE
|
||||
# idx_capacity_policy_users_policy_id (agent_capacity_policy_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (agent_capacity_policy_id => enterprise_agent_capacity_policies.id)
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
|
||||
class Enterprise::AgentCapacityPolicyUser < ApplicationRecord
|
||||
self.table_name = 'enterprise_agent_capacity_policy_users'
|
||||
|
||||
# Associations
|
||||
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy'
|
||||
belongs_to :user, class_name: '::User'
|
||||
|
||||
# Validations
|
||||
validates :user_id, uniqueness: true
|
||||
|
||||
# Delegations
|
||||
delegate :account, to: :agent_capacity_policy
|
||||
|
||||
# Callbacks
|
||||
after_commit :invalidate_user_cache
|
||||
|
||||
private
|
||||
|
||||
def invalidate_user_cache
|
||||
Rails.cache.delete_matched("assignment_v2:capacity:#{user_id}:*")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: enterprise_inbox_capacity_limits
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# agent_capacity_policy_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
# conversation_limit :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_inbox_limits_on_capacity_policy (agent_capacity_policy_id)
|
||||
# index_enterprise_inbox_capacity_limits_on_inbox_id (inbox_id)
|
||||
# unique_policy_inbox_limit (agent_capacity_policy_id,inbox_id) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (agent_capacity_policy_id => enterprise_agent_capacity_policies.id)
|
||||
# fk_rails_... (inbox_id => inboxes.id)
|
||||
#
|
||||
|
||||
class Enterprise::InboxCapacityLimit < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
self.table_name = 'enterprise_inbox_capacity_limits'
|
||||
|
||||
# Associations
|
||||
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy'
|
||||
belongs_to :inbox, class_name: '::Inbox'
|
||||
|
||||
# Validations
|
||||
validates :agent_capacity_policy_id, uniqueness: { scope: :inbox_id }
|
||||
validates :conversation_limit, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 1000 }
|
||||
|
||||
# Delegations
|
||||
delegate :account, to: :agent_capacity_policy
|
||||
delegate :name, :description, :exclusion_rules, to: :agent_capacity_policy, prefix: :policy
|
||||
|
||||
# Callbacks
|
||||
after_commit :invalidate_inbox_cache
|
||||
|
||||
# Scopes
|
||||
scope :for_inbox, ->(inbox) { where(inbox: inbox) }
|
||||
scope :for_policy, ->(policy) { where(agent_capacity_policy: policy) }
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
inbox_id: inbox_id,
|
||||
agent_capacity_policy_id: agent_capacity_policy_id,
|
||||
conversation_limit: conversation_limit,
|
||||
policy: agent_capacity_policy.webhook_data
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invalidate_inbox_cache
|
||||
Rails.cache.delete_matched("assignment_v2:capacity:*:#{inbox_id}")
|
||||
update_account_cache
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Enterprise::AgentCapacityPolicyPolicy < ApplicationPolicy
|
||||
def index?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def show?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def create?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def set_inbox_limit?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def remove_inbox_limit?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def assign_user?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def remove_user?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def agent_capacity?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Enterprise::AssignmentV2::BalancedSelector
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def select_agent(available_agents)
|
||||
return nil if available_agents.empty?
|
||||
|
||||
# Since agents are already filtered by capacity, we can compute workload distribution
|
||||
agents_with_workload = compute_agent_workloads(available_agents)
|
||||
return nil if agents_with_workload.empty?
|
||||
|
||||
# Select agent with lowest current workload
|
||||
best_agent_data = agents_with_workload.min_by { |data| data[:current_assignments] }
|
||||
best_agent_data[:agent]
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "AssignmentV2: Balanced selection failed: #{e.message}"
|
||||
# Fallback to simple selection
|
||||
available_agents.first&.user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def compute_agent_workloads(available_agents)
|
||||
available_agents.map do |inbox_member|
|
||||
agent = inbox_member.user
|
||||
|
||||
# Count current assignments
|
||||
current_assignments = agent.assigned_conversations
|
||||
.where(inbox: inbox)
|
||||
.open
|
||||
.count
|
||||
|
||||
{
|
||||
agent: agent,
|
||||
current_assignments: current_assignments
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Enterprise::AssignmentV2::CapacityService
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def filter_agents_by_capacity(inbox_members)
|
||||
inbox_members.select do |inbox_member|
|
||||
agent = inbox_member.user
|
||||
available_capacity?(agent)
|
||||
end
|
||||
end
|
||||
|
||||
def get_agent_capacity(agent)
|
||||
inbox_limit = get_inbox_limit_for_agent(agent)
|
||||
return unlimited_capacity unless inbox_limit
|
||||
|
||||
current_count = count_current_assignments(agent)
|
||||
build_capacity_data(inbox_limit, current_count)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_inbox_limit_for_agent(agent)
|
||||
account_user = agent.account_users.find_by(account: inbox.account)
|
||||
policy = account_user&.agent_capacity_policy
|
||||
return nil unless policy
|
||||
|
||||
policy.inbox_capacity_limits.find_by(inbox: inbox)
|
||||
end
|
||||
|
||||
def count_current_assignments(agent)
|
||||
agent.assigned_conversations
|
||||
.where(inbox: inbox)
|
||||
.open
|
||||
.count
|
||||
end
|
||||
|
||||
def build_capacity_data(inbox_limit, current_count)
|
||||
{
|
||||
total_capacity: inbox_limit.conversation_limit,
|
||||
current_assignments: current_count,
|
||||
available_capacity: inbox_limit.conversation_limit - current_count
|
||||
}
|
||||
end
|
||||
|
||||
def unlimited_capacity
|
||||
{
|
||||
total_capacity: Float::INFINITY,
|
||||
current_assignments: 0,
|
||||
available_capacity: Float::INFINITY
|
||||
}
|
||||
end
|
||||
|
||||
def available_capacity?(agent)
|
||||
capacity_data = get_agent_capacity(agent)
|
||||
capacity_data[:available_capacity].positive?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,187 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Enterprise::AssignmentV2::CapacityService
|
||||
def initialize
|
||||
@cache_ttl = 5.minutes
|
||||
end
|
||||
|
||||
# Get agent's current capacity status for specific inbox
|
||||
def get_agent_capacity(agent, inbox)
|
||||
cache_key = capacity_cache_key(agent, inbox)
|
||||
|
||||
cached = Redis::Alfred.hgetall(cache_key)
|
||||
return parse_cached_capacity(cached) if cached.present?
|
||||
|
||||
# Cache miss - compute from database
|
||||
capacity = compute_agent_capacity(agent, inbox)
|
||||
cache_capacity_data(cache_key, capacity)
|
||||
capacity
|
||||
end
|
||||
|
||||
# Get agent's overall capacity across all inboxes
|
||||
def get_agent_overall_capacity(agent)
|
||||
account = agent.accounts.first # Assuming we're working within account context
|
||||
policy = get_agent_capacity_policy(agent, account)
|
||||
|
||||
return unlimited_capacity_summary unless policy
|
||||
|
||||
capacity_data = build_capacity_data_for_policy(agent, policy)
|
||||
build_overall_capacity_response(policy, capacity_data)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_capacity_data_for_policy(agent, policy)
|
||||
inboxes_data = []
|
||||
total_current = 0
|
||||
total_limit = 0
|
||||
|
||||
policy.inbox_capacity_limits.includes(:inbox).each do |inbox_limit|
|
||||
inbox_data = build_inbox_capacity_data(agent, inbox_limit, policy)
|
||||
|
||||
total_current += inbox_data[:current_assignments]
|
||||
total_limit += inbox_data[:conversation_limit]
|
||||
inboxes_data << inbox_data
|
||||
end
|
||||
|
||||
{
|
||||
inboxes: inboxes_data,
|
||||
total_current: total_current,
|
||||
total_limit: total_limit
|
||||
}
|
||||
end
|
||||
|
||||
def build_inbox_capacity_data(agent, inbox_limit, policy)
|
||||
inbox = inbox_limit.inbox
|
||||
current = count_current_assignments(agent, inbox, policy)
|
||||
limit = inbox_limit.conversation_limit
|
||||
|
||||
{
|
||||
inbox_id: inbox.id,
|
||||
inbox_name: inbox.name,
|
||||
current_assignments: current,
|
||||
conversation_limit: limit,
|
||||
available_capacity: [limit - current, 0].max
|
||||
}
|
||||
end
|
||||
|
||||
def build_overall_capacity_response(policy, capacity_data)
|
||||
{
|
||||
policy_id: policy.id,
|
||||
policy_name: policy.name,
|
||||
total_current_assignments: capacity_data[:total_current],
|
||||
total_conversation_limit: capacity_data[:total_limit],
|
||||
total_available_capacity: [capacity_data[:total_limit] - capacity_data[:total_current], 0].max,
|
||||
exclusion_rules: policy.exclusion_rules,
|
||||
inboxes: capacity_data[:inboxes]
|
||||
}
|
||||
end
|
||||
|
||||
def compute_agent_capacity(agent, inbox)
|
||||
account = inbox.account
|
||||
policy = get_agent_capacity_policy(agent, account)
|
||||
|
||||
return unlimited_capacity if policy.nil?
|
||||
|
||||
inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox)
|
||||
return unlimited_capacity if inbox_limit.nil?
|
||||
|
||||
current_assignments = count_current_assignments(agent, inbox, policy)
|
||||
|
||||
{
|
||||
total_capacity: inbox_limit.conversation_limit,
|
||||
current_assignments: current_assignments,
|
||||
available_capacity: [inbox_limit.conversation_limit - current_assignments, 0].max,
|
||||
policy_id: policy.id,
|
||||
policy_name: policy.name,
|
||||
exclusion_rules: policy.exclusion_rules
|
||||
}
|
||||
end
|
||||
|
||||
def get_agent_capacity_policy(agent, account)
|
||||
account_user = account.account_users.find_by(user: agent)
|
||||
return nil unless account_user&.agent_capacity_policy_id
|
||||
|
||||
Enterprise::AgentCapacityPolicy.find_by(id: account_user.agent_capacity_policy_id)
|
||||
end
|
||||
|
||||
def count_current_assignments(agent, inbox, policy)
|
||||
scope = agent.assigned_conversations
|
||||
.where(inbox: inbox, status: 'open')
|
||||
|
||||
# Apply exclusion rules from policy
|
||||
scope = apply_exclusion_rules(scope, policy)
|
||||
scope.count
|
||||
end
|
||||
|
||||
def apply_exclusion_rules(scope, policy)
|
||||
rules = policy.exclusion_rules || {}
|
||||
|
||||
# Exclude conversations with specific labels
|
||||
if rules['labels'].present?
|
||||
scope = scope.where.not(id:
|
||||
ConversationLabel.joins(:label)
|
||||
.where(labels: { title: rules['labels'] })
|
||||
.select(:conversation_id))
|
||||
end
|
||||
|
||||
# Exclude conversations older than X hours
|
||||
if rules['hours_threshold'].present?
|
||||
cutoff = rules['hours_threshold'].hours.ago
|
||||
scope = scope.where('conversations.created_at > ?', cutoff)
|
||||
end
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def unlimited_capacity
|
||||
{
|
||||
total_capacity: Float::INFINITY,
|
||||
current_assignments: 0,
|
||||
available_capacity: Float::INFINITY,
|
||||
policy_id: nil,
|
||||
policy_name: 'No capacity policy',
|
||||
exclusion_rules: {}
|
||||
}
|
||||
end
|
||||
|
||||
def unlimited_capacity_summary
|
||||
{
|
||||
policy_id: nil,
|
||||
policy_name: 'No capacity policy',
|
||||
total_current_assignments: 0,
|
||||
total_conversation_limit: Float::INFINITY,
|
||||
total_available_capacity: Float::INFINITY,
|
||||
exclusion_rules: {},
|
||||
inboxes: []
|
||||
}
|
||||
end
|
||||
|
||||
def parse_cached_capacity(cached)
|
||||
{
|
||||
total_capacity: cached['total_capacity'].to_i,
|
||||
current_assignments: cached['current_assignments'].to_i,
|
||||
available_capacity: cached['available_capacity'].to_i,
|
||||
policy_id: cached['policy_id'].presence&.to_i,
|
||||
policy_name: cached['policy_name'] || 'No capacity policy',
|
||||
exclusion_rules: JSON.parse(cached['exclusion_rules'] || '{}')
|
||||
}
|
||||
end
|
||||
|
||||
def cache_capacity_data(cache_key, capacity)
|
||||
Redis::Alfred.multi do |multi|
|
||||
multi.hset(cache_key,
|
||||
'total_capacity', capacity[:total_capacity],
|
||||
'current_assignments', capacity[:current_assignments],
|
||||
'available_capacity', capacity[:available_capacity],
|
||||
'policy_id', capacity[:policy_id],
|
||||
'policy_name', capacity[:policy_name],
|
||||
'exclusion_rules', capacity[:exclusion_rules].to_json)
|
||||
multi.expire(cache_key, @cache_ttl)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_cache_key(agent, inbox)
|
||||
"assignment_v2:capacity:#{agent.id}:#{inbox.id}"
|
||||
end
|
||||
end
|
||||
@@ -78,6 +78,11 @@ module Redis::Alfred
|
||||
$alfred.with { |conn| conn.hmget(key, *fields) }
|
||||
end
|
||||
|
||||
# get all fields and values from redis hash
|
||||
def hgetall(key)
|
||||
$alfred.with { |conn| conn.hgetall(key) }
|
||||
end
|
||||
|
||||
# sorted set operations
|
||||
|
||||
# add score and value for a key
|
||||
@@ -100,5 +105,15 @@ module Redis::Alfred
|
||||
def zremrangebyscore(key, range_start, range_end)
|
||||
$alfred.with { |conn| conn.zremrangebyscore(key, range_start, range_end) }
|
||||
end
|
||||
|
||||
# transaction operations
|
||||
|
||||
def multi(&)
|
||||
$alfred.with { |conn| conn.multi(&) }
|
||||
end
|
||||
|
||||
def expire(key, expiry)
|
||||
$alfred.with { |conn| conn.expire(key, expiry) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
namespace :assignment_v2 do
|
||||
desc 'Enable Assignment V2 for an account with default policy'
|
||||
task :enable, [:account_id] => :environment do |_task, args|
|
||||
account = Account.find(args[:account_id])
|
||||
|
||||
# Create default assignment policy if not exists
|
||||
policy = account.assignment_policies.find_or_create_by!(name: 'Default Policy') do |p|
|
||||
p.description = 'Default round-robin assignment policy'
|
||||
p.assignment_order = 'round_robin'
|
||||
p.conversation_priority = 'earliest_created'
|
||||
p.enabled = true
|
||||
end
|
||||
|
||||
puts "Assignment V2 enabled for account #{account.name} with policy #{policy.name}"
|
||||
end
|
||||
|
||||
desc 'Disable Assignment V2 for an account'
|
||||
task :disable, [:account_id] => :environment do |_task, args|
|
||||
account = Account.find(args[:account_id])
|
||||
|
||||
# Disable all assignment policies
|
||||
account.assignment_policies.find_each do |policy|
|
||||
policy.update!(enabled: false)
|
||||
end
|
||||
|
||||
puts "Assignment V2 disabled for account #{account.name}"
|
||||
end
|
||||
|
||||
desc 'Run assignment for all enabled inboxes'
|
||||
task run_all: :environment do
|
||||
count = 0
|
||||
|
||||
Inbox.joins(:assignment_policy)
|
||||
.where(assignment_policies: { enabled: true })
|
||||
.find_each do |inbox|
|
||||
AssignmentV2::AssignmentJob.perform_later(inbox_id: inbox.id)
|
||||
count += 1
|
||||
end
|
||||
|
||||
puts "Queued assignment jobs for #{count} inboxes"
|
||||
end
|
||||
|
||||
desc 'Show Assignment V2 status'
|
||||
task status: :environment do
|
||||
puts 'Assignment V2 Status'
|
||||
puts '==================='
|
||||
|
||||
total_policies = AssignmentPolicy.count
|
||||
enabled_policies = AssignmentPolicy.enabled.count
|
||||
|
||||
puts "Total Assignment Policies: #{total_policies}"
|
||||
puts "Enabled Policies: #{enabled_policies}"
|
||||
puts
|
||||
|
||||
inboxes_with_v2 = Inbox.joins(:assignment_policy).count
|
||||
total_inboxes = Inbox.count
|
||||
|
||||
puts "Total Inboxes: #{total_inboxes}"
|
||||
puts "Inboxes with Assignment V2: #{inboxes_with_v2}"
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
+8
-8
@@ -56,16 +56,16 @@
|
||||
"@vuelidate/validators": "^2.0.4",
|
||||
"@vueuse/components": "^12.0.0",
|
||||
"@vueuse/core": "^12.0.0",
|
||||
"activestorage": "^5.2.6",
|
||||
"activestorage": "^5.2.8",
|
||||
"axios": "^1.8.2",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"chart.js": "~4.4.4",
|
||||
"color2k": "^2.0.2",
|
||||
"color2k": "^2.0.3",
|
||||
"company-email-validator": "^1.1.0",
|
||||
"core-js": "3.38.1",
|
||||
"countries-and-timezones": "^3.6.0",
|
||||
"date-fns": "2.21.1",
|
||||
"date-fns-tz": "^1.3.3",
|
||||
"date-fns-tz": "^1.3.8",
|
||||
"dompurify": "3.2.4",
|
||||
"flag-icons": "^7.2.3",
|
||||
"floating-vue": "^5.2.2",
|
||||
@@ -98,7 +98,7 @@
|
||||
"vue-multiselect": "3.1.0",
|
||||
"vue-router": "~4.4.5",
|
||||
"vue-upload-component": "^3.1.17",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8",
|
||||
"vue-virtual-scroller": "2.0.0-beta.8",
|
||||
"vue3-click-away": "^1.2.4",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuex": "~4.1.0",
|
||||
@@ -114,7 +114,7 @@
|
||||
"@iconify-json/ri": "^1.2.3",
|
||||
"@iconify-json/teenyicons": "^1.2.1",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@size-limit/file": "^8.2.4",
|
||||
"@size-limit/file": "^8.2.6",
|
||||
"@vitest/coverage-v8": "3.0.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
@@ -129,16 +129,16 @@
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"histoire": "0.17.15",
|
||||
"husky": "^7.0.0",
|
||||
"husky": "^7.0.4",
|
||||
"jsdom": "^24.1.3",
|
||||
"lint-staged": "14.0.1",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-preset-env": "^8.5.1",
|
||||
"prettier": "^3.3.3",
|
||||
"prosemirror-model": "^1.22.3",
|
||||
"size-limit": "^8.2.4",
|
||||
"size-limit": "^8.2.6",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"vite": "^5.4.19",
|
||||
"vite": "5.4.19",
|
||||
"vite-plugin-ruby": "^5.0.0",
|
||||
"vitest": "3.0.5"
|
||||
},
|
||||
|
||||
Generated
+54
-23
@@ -89,7 +89,7 @@ importers:
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.0(typescript@5.6.2)
|
||||
activestorage:
|
||||
specifier: ^5.2.6
|
||||
specifier: ^5.2.8
|
||||
version: 5.2.8
|
||||
axios:
|
||||
specifier: ^1.8.2
|
||||
@@ -101,7 +101,7 @@ importers:
|
||||
specifier: ~4.4.4
|
||||
version: 4.4.4
|
||||
color2k:
|
||||
specifier: ^2.0.2
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3
|
||||
company-email-validator:
|
||||
specifier: ^1.1.0
|
||||
@@ -116,7 +116,7 @@ importers:
|
||||
specifier: 2.21.1
|
||||
version: 2.21.1
|
||||
date-fns-tz:
|
||||
specifier: ^1.3.3
|
||||
specifier: ^1.3.8
|
||||
version: 1.3.8(date-fns@2.21.1)
|
||||
dompurify:
|
||||
specifier: 3.2.4
|
||||
@@ -215,7 +215,7 @@ importers:
|
||||
specifier: ^3.1.17
|
||||
version: 3.1.17
|
||||
vue-virtual-scroller:
|
||||
specifier: ^2.0.0-beta.8
|
||||
specifier: 2.0.0-beta.8
|
||||
version: 2.0.0-beta.8(vue@3.5.12(typescript@5.6.2))
|
||||
vue3-click-away:
|
||||
specifier: ^1.2.4
|
||||
@@ -258,7 +258,7 @@ importers:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0(eslint@8.57.0)
|
||||
'@size-limit/file':
|
||||
specifier: ^8.2.4
|
||||
specifier: ^8.2.6
|
||||
version: 8.2.6(size-limit@8.2.6)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 3.0.5
|
||||
@@ -303,7 +303,7 @@ importers:
|
||||
specifier: 0.17.15
|
||||
version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
husky:
|
||||
specifier: ^7.0.0
|
||||
specifier: ^7.0.4
|
||||
version: 7.0.4
|
||||
jsdom:
|
||||
specifier: ^24.1.3
|
||||
@@ -324,7 +324,7 @@ importers:
|
||||
specifier: ^1.22.3
|
||||
version: 1.22.3
|
||||
size-limit:
|
||||
specifier: ^8.2.4
|
||||
specifier: ^8.2.6
|
||||
version: 8.2.6
|
||||
tailwindcss:
|
||||
specifier: ^3.4.13
|
||||
@@ -940,6 +940,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.12':
|
||||
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.5':
|
||||
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -952,19 +955,29 @@ packages:
|
||||
resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/set-array@1.2.1':
|
||||
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
|
||||
'@jridgewell/source-map@0.3.10':
|
||||
resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0':
|
||||
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.4':
|
||||
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.29':
|
||||
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
|
||||
|
||||
'@june-so/analytics-next@2.0.0':
|
||||
resolution: {integrity: sha512-7uFP94JLD7mP4qLyOwn5HBs+CC8VlevOkiGd1CIYqPSjSRmbCOI+MVcJNlTAcpyNvMi9iUnWZ3jGVO5177Di4A==}
|
||||
|
||||
@@ -1993,8 +2006,8 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.14.1:
|
||||
resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==}
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2203,8 +2216,8 @@ packages:
|
||||
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
caniuse-lite@1.0.30001651:
|
||||
resolution: {integrity: sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==}
|
||||
caniuse-lite@1.0.30001731:
|
||||
resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==}
|
||||
|
||||
capital-case@1.0.4:
|
||||
resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
|
||||
@@ -5833,6 +5846,12 @@ snapshots:
|
||||
|
||||
'@istanbuljs/schema@0.1.3': {}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.12':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.4
|
||||
'@jridgewell/trace-mapping': 0.3.29
|
||||
optional: true
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.5':
|
||||
dependencies:
|
||||
'@jridgewell/set-array': 1.2.1
|
||||
@@ -5847,21 +5866,33 @@ snapshots:
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.1': {}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/set-array@1.2.1': {}
|
||||
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
'@jridgewell/source-map@0.3.10':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.8
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
'@jridgewell/gen-mapping': 0.3.12
|
||||
'@jridgewell/trace-mapping': 0.3.29
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.4':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.1
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.29':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.4
|
||||
optional: true
|
||||
|
||||
'@june-so/analytics-next@2.0.0':
|
||||
dependencies:
|
||||
'@lukeed/uuid': 2.0.0
|
||||
@@ -7078,7 +7109,7 @@ snapshots:
|
||||
|
||||
acorn@8.14.0: {}
|
||||
|
||||
acorn@8.14.1:
|
||||
acorn@8.15.0:
|
||||
optional: true
|
||||
|
||||
activestorage@5.2.8:
|
||||
@@ -7227,7 +7258,7 @@ snapshots:
|
||||
autoprefixer@10.4.20(postcss@8.4.47):
|
||||
dependencies:
|
||||
browserslist: 4.23.3
|
||||
caniuse-lite: 1.0.30001651
|
||||
caniuse-lite: 1.0.30001731
|
||||
fraction.js: 4.3.7
|
||||
normalize-range: 0.1.2
|
||||
picocolors: 1.0.1
|
||||
@@ -7282,14 +7313,14 @@ snapshots:
|
||||
|
||||
browserslist@4.23.0:
|
||||
dependencies:
|
||||
caniuse-lite: 1.0.30001651
|
||||
caniuse-lite: 1.0.30001731
|
||||
electron-to-chromium: 1.4.783
|
||||
node-releases: 2.0.14
|
||||
update-browserslist-db: 1.0.16(browserslist@4.23.0)
|
||||
|
||||
browserslist@4.23.3:
|
||||
dependencies:
|
||||
caniuse-lite: 1.0.30001651
|
||||
caniuse-lite: 1.0.30001731
|
||||
electron-to-chromium: 1.5.13
|
||||
node-releases: 2.0.18
|
||||
update-browserslist-db: 1.1.0(browserslist@4.23.3)
|
||||
@@ -7337,7 +7368,7 @@ snapshots:
|
||||
|
||||
camelcase@8.0.0: {}
|
||||
|
||||
caniuse-lite@1.0.30001651: {}
|
||||
caniuse-lite@1.0.30001731: {}
|
||||
|
||||
capital-case@1.0.4:
|
||||
dependencies:
|
||||
@@ -10124,8 +10155,8 @@ snapshots:
|
||||
|
||||
terser@5.33.0:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.6
|
||||
acorn: 8.14.1
|
||||
'@jridgewell/source-map': 0.3.10
|
||||
acorn: 8.15.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
optional: true
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Leaves API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:agent_account_user) { account.account_users.find_by(user: agent) }
|
||||
let(:another_agent) { create(:user, account: account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/leaves' do
|
||||
context 'when authenticated as an agent' do
|
||||
it 'returns only their own leaves' do
|
||||
leave1 = create(:leave, account_user: agent_account_user, account: account)
|
||||
create(:leave, account_user: account.account_users.find_by(user: another_agent), account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/leaves",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['leaves'].size).to eq(1)
|
||||
expect(json_response['leaves'].first['id']).to eq(leave1.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as an admin' do
|
||||
it 'returns all leaves in the account' do
|
||||
create(:leave, account_user: agent_account_user, account: account)
|
||||
create(:leave, account_user: account.account_users.find_by(user: another_agent), account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/leaves",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['leaves'].size).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/leaves' do
|
||||
context 'when authenticated as an agent' do
|
||||
it 'creates a leave request for themselves' do
|
||||
leave_params = {
|
||||
leave: {
|
||||
start_date: Date.current + 1.day,
|
||||
end_date: Date.current + 7.days,
|
||||
leave_type: 'vacation',
|
||||
reason: 'Annual vacation'
|
||||
}
|
||||
}
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/leaves",
|
||||
params: leave_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['leave']['status']).to eq('pending')
|
||||
expect(json_response['leave']['leave_type']).to eq('vacation')
|
||||
end
|
||||
|
||||
it 'validates date order' do
|
||||
leave_params = {
|
||||
leave: {
|
||||
start_date: Date.current + 7.days,
|
||||
end_date: Date.current + 1.day,
|
||||
leave_type: 'vacation',
|
||||
reason: 'Invalid dates'
|
||||
}
|
||||
}
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/leaves",
|
||||
params: leave_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['errors']).to include('End date must be after or equal to start date')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/:account_id/leaves/:id' do
|
||||
let(:leave) { create(:leave, account_user: agent_account_user, account: account) }
|
||||
|
||||
context 'when authenticated as the leave owner' do
|
||||
it 'updates pending leave' do
|
||||
update_params = {
|
||||
leave: {
|
||||
end_date: Date.current + 10.days,
|
||||
reason: 'Extended vacation'
|
||||
}
|
||||
}
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
|
||||
params: update_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['leave']['reason']).to eq('Extended vacation')
|
||||
end
|
||||
|
||||
it 'cannot update approved leave' do
|
||||
leave.update!(status: 'approved')
|
||||
|
||||
update_params = {
|
||||
leave: {
|
||||
end_date: Date.current + 10.days
|
||||
}
|
||||
}
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
|
||||
params: update_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq('You are not authorized to do this action')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/leaves/:id/approve' do
|
||||
let(:leave) { create(:leave, account_user: agent_account_user, account: account) }
|
||||
|
||||
context 'when authenticated as an admin' do
|
||||
it 'approves the leave' do
|
||||
post "/api/v1/accounts/#{account.id}/leaves/#{leave.id}/approve",
|
||||
params: { comments: 'Approved for vacation' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['leave']['status']).to eq('approved')
|
||||
expect(json_response['leave']['approved_by']).to eq(admin.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as a regular agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/leaves/#{leave.id}/approve",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq('You are not authorized to do this action')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/leaves/:id/reject' do
|
||||
let(:leave) { create(:leave, account_user: agent_account_user, account: account) }
|
||||
|
||||
context 'when authenticated as an admin' do
|
||||
it 'rejects the leave with reason' do
|
||||
post "/api/v1/accounts/#{account.id}/leaves/#{leave.id}/reject",
|
||||
params: { reason: 'Not enough coverage' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['leave']['status']).to eq('rejected')
|
||||
end
|
||||
|
||||
it 'requires rejection reason' do
|
||||
post "/api/v1/accounts/#{account.id}/leaves/#{leave.id}/reject",
|
||||
params: { reason: '' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['errors']).to include('Rejection reason is required')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/leaves/:id' do
|
||||
context 'when deleting own pending leave' do
|
||||
let(:leave) { create(:leave, account_user: agent_account_user, account: account) }
|
||||
|
||||
it 'deletes the leave' do
|
||||
delete "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(Leave.find_by(id: leave.id)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when trying to delete approved leave' do
|
||||
let(:leave) { create(:leave, :approved, account_user: agent_account_user, account: account) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq('You are not authorized to do this action')
|
||||
expect(Leave.find_by(id: leave.id)).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user