escalated 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +302 -0
- data/app/controllers/escalated/admin/bulk_actions_controller.rb +42 -0
- data/app/controllers/escalated/admin/canned_responses_controller.rb +73 -0
- data/app/controllers/escalated/admin/departments_controller.rb +135 -0
- data/app/controllers/escalated/admin/escalation_rules_controller.rb +121 -0
- data/app/controllers/escalated/admin/macros_controller.rb +73 -0
- data/app/controllers/escalated/admin/reports_controller.rb +152 -0
- data/app/controllers/escalated/admin/settings_controller.rb +111 -0
- data/app/controllers/escalated/admin/sla_policies_controller.rb +109 -0
- data/app/controllers/escalated/admin/tags_controller.rb +67 -0
- data/app/controllers/escalated/admin/tickets_controller.rb +299 -0
- data/app/controllers/escalated/agent/bulk_actions_controller.rb +42 -0
- data/app/controllers/escalated/agent/dashboard_controller.rb +94 -0
- data/app/controllers/escalated/agent/tickets_controller.rb +330 -0
- data/app/controllers/escalated/application_controller.rb +110 -0
- data/app/controllers/escalated/customer/satisfaction_ratings_controller.rb +44 -0
- data/app/controllers/escalated/customer/tickets_controller.rb +169 -0
- data/app/controllers/escalated/guest/tickets_controller.rb +231 -0
- data/app/controllers/escalated/inbound_controller.rb +79 -0
- data/app/jobs/escalated/check_sla_job.rb +36 -0
- data/app/jobs/escalated/close_resolved_job.rb +51 -0
- data/app/jobs/escalated/evaluate_escalations_job.rb +24 -0
- data/app/jobs/escalated/poll_imap_job.rb +74 -0
- data/app/jobs/escalated/purge_activities_job.rb +24 -0
- data/app/mailers/escalated/application_mailer.rb +6 -0
- data/app/mailers/escalated/ticket_mailer.rb +93 -0
- data/app/models/escalated/application_record.rb +5 -0
- data/app/models/escalated/attachment.rb +46 -0
- data/app/models/escalated/canned_response.rb +45 -0
- data/app/models/escalated/department.rb +43 -0
- data/app/models/escalated/escalated_setting.rb +43 -0
- data/app/models/escalated/escalation_rule.rb +96 -0
- data/app/models/escalated/inbound_email.rb +60 -0
- data/app/models/escalated/macro.rb +18 -0
- data/app/models/escalated/reply.rb +42 -0
- data/app/models/escalated/satisfaction_rating.rb +21 -0
- data/app/models/escalated/sla_policy.rb +54 -0
- data/app/models/escalated/tag.rb +28 -0
- data/app/models/escalated/ticket.rb +166 -0
- data/app/models/escalated/ticket_activity.rb +60 -0
- data/app/policies/escalated/canned_response_policy.rb +40 -0
- data/app/policies/escalated/department_policy.rb +36 -0
- data/app/policies/escalated/escalation_rule_policy.rb +36 -0
- data/app/policies/escalated/sla_policy_policy.rb +36 -0
- data/app/policies/escalated/tag_policy.rb +36 -0
- data/app/policies/escalated/ticket_policy.rb +111 -0
- data/config/routes.rb +81 -0
- data/db/migrate/001_create_escalated_departments.rb +18 -0
- data/db/migrate/002_create_escalated_sla_policies.rb +23 -0
- data/db/migrate/003_create_escalated_tags.rb +15 -0
- data/db/migrate/004_create_escalated_tickets.rb +48 -0
- data/db/migrate/005_create_escalated_replies.rb +21 -0
- data/db/migrate/006_create_escalated_attachments.rb +17 -0
- data/db/migrate/007_create_escalated_ticket_tags.rb +13 -0
- data/db/migrate/008_create_escalated_support_tables.rb +49 -0
- data/db/migrate/009_create_escalated_ticket_activities.rb +20 -0
- data/db/migrate/010_create_escalated_settings.rb +29 -0
- data/db/migrate/011_add_guest_fields_to_escalated_tickets.rb +28 -0
- data/db/migrate/012_create_escalated_inbound_emails.rb +30 -0
- data/db/migrate/013_create_escalated_macros.rb +18 -0
- data/db/migrate/014_create_escalated_ticket_followers.rb +18 -0
- data/db/migrate/015_create_escalated_satisfaction_ratings.rb +21 -0
- data/db/migrate/016_add_is_pinned_to_escalated_replies.rb +6 -0
- data/lib/escalated/configuration.rb +111 -0
- data/lib/escalated/drivers/cloud_driver.rb +134 -0
- data/lib/escalated/drivers/hosted_api_client.rb +166 -0
- data/lib/escalated/drivers/local_driver.rb +341 -0
- data/lib/escalated/drivers/synced_driver.rb +124 -0
- data/lib/escalated/engine.rb +45 -0
- data/lib/escalated/mail/adapters/base_adapter.rb +60 -0
- data/lib/escalated/mail/adapters/imap_adapter.rb +209 -0
- data/lib/escalated/mail/adapters/mailgun_adapter.rb +93 -0
- data/lib/escalated/mail/adapters/postmark_adapter.rb +94 -0
- data/lib/escalated/mail/adapters/ses_adapter.rb +179 -0
- data/lib/escalated/mail/inbound_message.rb +78 -0
- data/lib/escalated/manager.rb +33 -0
- data/lib/escalated/services/assignment_service.rb +85 -0
- data/lib/escalated/services/attachment_service.rb +110 -0
- data/lib/escalated/services/escalation_service.rb +159 -0
- data/lib/escalated/services/inbound_email_service.rb +255 -0
- data/lib/escalated/services/macro_service.rb +49 -0
- data/lib/escalated/services/notification_service.rb +157 -0
- data/lib/escalated/services/sla_service.rb +203 -0
- data/lib/escalated/services/ticket_service.rb +113 -0
- data/lib/escalated.rb +25 -0
- data/lib/generators/escalated/install_generator.rb +75 -0
- data/lib/generators/escalated/templates/initializer.rb +89 -0
- metadata +227 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Admin
|
|
3
|
+
class TicketsController < Escalated::ApplicationController
|
|
4
|
+
before_action :require_admin!
|
|
5
|
+
before_action :set_ticket, only: [:show, :reply, :note, :assign, :status, :priority, :tags, :department, :apply_macro, :follow, :presence, :pin]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
scope = Escalated::Ticket.all.recent
|
|
9
|
+
|
|
10
|
+
scope = scope.where(status: params[:status]) if params[:status].present?
|
|
11
|
+
scope = scope.where(priority: params[:priority]) if params[:priority].present?
|
|
12
|
+
scope = scope.assigned_to(params[:assigned_to]) if params[:assigned_to].present?
|
|
13
|
+
scope = scope.where(department_id: params[:department_id]) if params[:department_id].present?
|
|
14
|
+
scope = scope.unassigned if params[:unassigned] == "true"
|
|
15
|
+
scope = scope.breached_sla if params[:sla_breached] == "true"
|
|
16
|
+
scope = scope.search(params[:search]) if params[:search].present?
|
|
17
|
+
|
|
18
|
+
# Following filter
|
|
19
|
+
if params[:following] == "true"
|
|
20
|
+
followed_ticket_ids = Escalated::Ticket
|
|
21
|
+
.joins("INNER JOIN #{Escalated.table_name('ticket_followers')} ON #{Escalated.table_name('ticket_followers')}.ticket_id = #{Escalated.table_name('tickets')}.id")
|
|
22
|
+
.where("#{Escalated.table_name('ticket_followers')}.user_id = ?", escalated_current_user.id)
|
|
23
|
+
.pluck(:id)
|
|
24
|
+
scope = scope.where(id: followed_ticket_ids)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
result = paginate(scope)
|
|
28
|
+
|
|
29
|
+
render inertia: "Escalated/Admin/Tickets/Index", props: {
|
|
30
|
+
tickets: result[:data].includes(:requester, :department, :assignee, :tags).map { |t| ticket_list_json(t) },
|
|
31
|
+
meta: result[:meta],
|
|
32
|
+
filters: {
|
|
33
|
+
status: params[:status],
|
|
34
|
+
priority: params[:priority],
|
|
35
|
+
assigned_to: params[:assigned_to],
|
|
36
|
+
department_id: params[:department_id],
|
|
37
|
+
unassigned: params[:unassigned],
|
|
38
|
+
sla_breached: params[:sla_breached],
|
|
39
|
+
search: params[:search],
|
|
40
|
+
following: params[:following]
|
|
41
|
+
},
|
|
42
|
+
departments: Escalated::Department.active.ordered.map { |d| { id: d.id, name: d.name } },
|
|
43
|
+
agents: agent_list,
|
|
44
|
+
tags: Escalated::Tag.ordered.map { |t| { id: t.id, name: t.name, color: t.color } },
|
|
45
|
+
statuses: Escalated::Ticket.statuses.keys,
|
|
46
|
+
priorities: Escalated::Ticket.priorities.keys
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def show
|
|
51
|
+
replies = @ticket.replies.chronological.includes(:author, :attachments)
|
|
52
|
+
activities = @ticket.activities.reverse_chronological.limit(50)
|
|
53
|
+
|
|
54
|
+
render inertia: "Escalated/Admin/Tickets/Show", props: {
|
|
55
|
+
ticket: ticket_detail_json(@ticket),
|
|
56
|
+
replies: replies.map { |r| reply_json(r) },
|
|
57
|
+
activities: activities.map { |a| activity_json(a) },
|
|
58
|
+
departments: Escalated::Department.active.ordered.map { |d| { id: d.id, name: d.name } },
|
|
59
|
+
agents: agent_list,
|
|
60
|
+
tags: Escalated::Tag.ordered.map { |t| { id: t.id, name: t.name, color: t.color } },
|
|
61
|
+
canned_responses: Escalated::CannedResponse.for_user(escalated_current_user.id).ordered.map { |c|
|
|
62
|
+
{ id: c.id, title: c.title, body: c.body, shortcode: c.shortcode }
|
|
63
|
+
},
|
|
64
|
+
macros: Escalated::Macro.for_agent(escalated_current_user.id).ordered.map { |m|
|
|
65
|
+
{ id: m.id, name: m.name, description: m.description, actions: m.actions }
|
|
66
|
+
},
|
|
67
|
+
is_following: @ticket.followed_by?(escalated_current_user.id),
|
|
68
|
+
followers_count: @ticket.followers.count,
|
|
69
|
+
statuses: Escalated::Ticket.statuses.keys,
|
|
70
|
+
priorities: Escalated::Ticket.priorities.keys
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reply
|
|
75
|
+
reply = Services::TicketService.reply(@ticket, {
|
|
76
|
+
body: params[:body],
|
|
77
|
+
author: escalated_current_user,
|
|
78
|
+
is_internal: false
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if params[:attachments].present?
|
|
82
|
+
Services::AttachmentService.attach(reply, params[:attachments])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
redirect_to admin_ticket_path(@ticket), notice: "Reply sent."
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def note
|
|
89
|
+
Services::TicketService.reply(@ticket, {
|
|
90
|
+
body: params[:body],
|
|
91
|
+
author: escalated_current_user,
|
|
92
|
+
is_internal: true
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
redirect_to admin_ticket_path(@ticket), notice: "Internal note added."
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def assign
|
|
99
|
+
if params[:agent_id].present?
|
|
100
|
+
agent = Escalated.configuration.user_model.find(params[:agent_id])
|
|
101
|
+
Services::AssignmentService.assign(@ticket, agent, actor: escalated_current_user)
|
|
102
|
+
redirect_to admin_ticket_path(@ticket), notice: "Ticket assigned to #{agent.respond_to?(:name) ? agent.name : agent.email}."
|
|
103
|
+
else
|
|
104
|
+
Services::AssignmentService.unassign(@ticket, actor: escalated_current_user)
|
|
105
|
+
redirect_to admin_ticket_path(@ticket), notice: "Ticket unassigned."
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def status
|
|
110
|
+
Services::TicketService.transition_status(
|
|
111
|
+
@ticket,
|
|
112
|
+
params[:status],
|
|
113
|
+
actor: escalated_current_user,
|
|
114
|
+
note: params[:note]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
redirect_to admin_ticket_path(@ticket), notice: "Status updated to #{params[:status].humanize}."
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def priority
|
|
121
|
+
Services::TicketService.change_priority(@ticket, params[:priority], actor: escalated_current_user)
|
|
122
|
+
redirect_to admin_ticket_path(@ticket), notice: "Priority updated to #{params[:priority]}."
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def tags
|
|
126
|
+
if params[:add_tag_ids].present?
|
|
127
|
+
Services::TicketService.add_tags(@ticket, params[:add_tag_ids], actor: escalated_current_user)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if params[:remove_tag_ids].present?
|
|
131
|
+
Services::TicketService.remove_tags(@ticket, params[:remove_tag_ids], actor: escalated_current_user)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
redirect_to admin_ticket_path(@ticket), notice: "Tags updated."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def department
|
|
138
|
+
dept = Escalated::Department.find(params[:department_id])
|
|
139
|
+
Services::TicketService.change_department(@ticket, dept, actor: escalated_current_user)
|
|
140
|
+
redirect_to admin_ticket_path(@ticket), notice: "Department changed to #{dept.name}."
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def apply_macro
|
|
144
|
+
macro = Escalated::Macro.for_agent(escalated_current_user.id).find(params[:macro_id])
|
|
145
|
+
Services::MacroService.apply(macro, @ticket, actor: escalated_current_user)
|
|
146
|
+
|
|
147
|
+
redirect_to admin_ticket_path(@ticket), notice: "Macro \"#{macro.name}\" applied."
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def follow
|
|
151
|
+
if @ticket.followed_by?(escalated_current_user.id)
|
|
152
|
+
@ticket.unfollow(escalated_current_user.id)
|
|
153
|
+
redirect_to admin_ticket_path(@ticket), notice: "Unfollowed ticket."
|
|
154
|
+
else
|
|
155
|
+
@ticket.follow(escalated_current_user.id)
|
|
156
|
+
redirect_to admin_ticket_path(@ticket), notice: "Following ticket."
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def presence
|
|
161
|
+
user_id = escalated_current_user.id
|
|
162
|
+
user_name = escalated_current_user.respond_to?(:name) ? escalated_current_user.name : escalated_current_user.email
|
|
163
|
+
cache_key = "escalated.presence.#{@ticket.id}.#{user_id}"
|
|
164
|
+
|
|
165
|
+
Rails.cache.write(cache_key, { id: user_id, name: user_name }, expires_in: 30.seconds)
|
|
166
|
+
|
|
167
|
+
# Track active user IDs for this ticket
|
|
168
|
+
list_key = "escalated.presence_list.#{@ticket.id}"
|
|
169
|
+
active_ids = Rails.cache.read(list_key) || []
|
|
170
|
+
active_ids << user_id unless active_ids.include?(user_id)
|
|
171
|
+
Rails.cache.write(list_key, active_ids, expires_in: 2.minutes)
|
|
172
|
+
|
|
173
|
+
# Collect viewers (exclude current user)
|
|
174
|
+
viewers = []
|
|
175
|
+
active_ids.each do |uid|
|
|
176
|
+
next if uid == user_id
|
|
177
|
+
|
|
178
|
+
viewer = Rails.cache.read("escalated.presence.#{@ticket.id}.#{uid}")
|
|
179
|
+
viewers << viewer if viewer
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
render json: { viewers: viewers }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def pin
|
|
186
|
+
reply = @ticket.replies.find(params[:reply_id])
|
|
187
|
+
|
|
188
|
+
unless reply.is_internal
|
|
189
|
+
redirect_to admin_ticket_path(@ticket), alert: "Only internal notes can be pinned."
|
|
190
|
+
return
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
reply.update!(is_pinned: !reply.is_pinned)
|
|
194
|
+
|
|
195
|
+
redirect_to admin_ticket_path(@ticket),
|
|
196
|
+
notice: reply.is_pinned ? "Note pinned." : "Note unpinned."
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def set_ticket
|
|
202
|
+
@ticket = Escalated::Ticket.find_by!(reference: params[:id])
|
|
203
|
+
rescue ActiveRecord::RecordNotFound
|
|
204
|
+
@ticket = Escalated::Ticket.find(params[:id])
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def admin_ticket_path(ticket)
|
|
208
|
+
escalated.admin_ticket_path(ticket)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def agent_list
|
|
212
|
+
if Escalated.configuration.user_model.respond_to?(:escalated_agents)
|
|
213
|
+
Escalated.configuration.user_model.escalated_agents.map { |a|
|
|
214
|
+
{ id: a.id, name: a.respond_to?(:name) ? a.name : a.email, email: a.email }
|
|
215
|
+
}
|
|
216
|
+
else
|
|
217
|
+
[]
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def ticket_list_json(ticket)
|
|
222
|
+
{
|
|
223
|
+
id: ticket.id,
|
|
224
|
+
reference: ticket.reference,
|
|
225
|
+
subject: ticket.subject,
|
|
226
|
+
status: ticket.status,
|
|
227
|
+
priority: ticket.priority,
|
|
228
|
+
requester: {
|
|
229
|
+
name: ticket.requester.respond_to?(:name) ? ticket.requester.name : ticket.requester&.email
|
|
230
|
+
},
|
|
231
|
+
assignee: ticket.assignee ? {
|
|
232
|
+
id: ticket.assignee.id,
|
|
233
|
+
name: ticket.assignee.respond_to?(:name) ? ticket.assignee.name : ticket.assignee.email
|
|
234
|
+
} : nil,
|
|
235
|
+
department: ticket.department ? { id: ticket.department.id, name: ticket.department.name } : nil,
|
|
236
|
+
tags: ticket.tags.map { |t| { id: t.id, name: t.name, color: t.color } },
|
|
237
|
+
sla_breached: ticket.sla_breached,
|
|
238
|
+
created_at: ticket.created_at&.iso8601,
|
|
239
|
+
updated_at: ticket.updated_at&.iso8601
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def ticket_detail_json(ticket)
|
|
244
|
+
ticket_list_json(ticket).merge(
|
|
245
|
+
description: ticket.description,
|
|
246
|
+
metadata: ticket.metadata,
|
|
247
|
+
sla_policy: ticket.sla_policy ? { id: ticket.sla_policy.id, name: ticket.sla_policy.name } : nil,
|
|
248
|
+
sla_first_response_due_at: ticket.sla_first_response_due_at&.iso8601,
|
|
249
|
+
sla_resolution_due_at: ticket.sla_resolution_due_at&.iso8601,
|
|
250
|
+
first_response_at: ticket.first_response_at&.iso8601,
|
|
251
|
+
resolved_at: ticket.resolved_at&.iso8601,
|
|
252
|
+
closed_at: ticket.closed_at&.iso8601,
|
|
253
|
+
reply_count: ticket.replies.count,
|
|
254
|
+
attachment_count: ticket.attachments.count,
|
|
255
|
+
satisfaction_rating: ticket.satisfaction_rating ? {
|
|
256
|
+
id: ticket.satisfaction_rating.id,
|
|
257
|
+
rating: ticket.satisfaction_rating.rating,
|
|
258
|
+
comment: ticket.satisfaction_rating.comment,
|
|
259
|
+
created_at: ticket.satisfaction_rating.created_at&.iso8601
|
|
260
|
+
} : nil,
|
|
261
|
+
pinned_notes: ticket.pinned_notes.includes(:author).map { |n| reply_json(n) }
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def reply_json(reply)
|
|
266
|
+
{
|
|
267
|
+
id: reply.id,
|
|
268
|
+
body: reply.body,
|
|
269
|
+
is_internal: reply.is_internal,
|
|
270
|
+
is_internal_note: reply.is_internal,
|
|
271
|
+
is_system: reply.is_system,
|
|
272
|
+
is_pinned: reply.respond_to?(:is_pinned) ? reply.is_pinned : false,
|
|
273
|
+
author: reply.author ? {
|
|
274
|
+
id: reply.author.id,
|
|
275
|
+
name: reply.author.respond_to?(:name) ? reply.author.name : reply.author.email,
|
|
276
|
+
is_agent: reply.author.respond_to?(:escalated_agent?) ? reply.author.escalated_agent? : false
|
|
277
|
+
} : { name: "System", is_agent: true },
|
|
278
|
+
attachments: reply.attachments.map { |a|
|
|
279
|
+
{ id: a.id, filename: a.filename, size: a.human_size, content_type: a.content_type }
|
|
280
|
+
},
|
|
281
|
+
created_at: reply.created_at&.iso8601
|
|
282
|
+
}
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def activity_json(activity)
|
|
286
|
+
{
|
|
287
|
+
id: activity.id,
|
|
288
|
+
action: activity.action,
|
|
289
|
+
description: activity.description,
|
|
290
|
+
causer: activity.causer ? {
|
|
291
|
+
name: activity.causer.respond_to?(:name) ? activity.causer.name : activity.causer.email
|
|
292
|
+
} : nil,
|
|
293
|
+
details: activity.details,
|
|
294
|
+
created_at: activity.created_at&.iso8601
|
|
295
|
+
}
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Agent
|
|
3
|
+
class BulkActionsController < Escalated::ApplicationController
|
|
4
|
+
before_action :require_agent!
|
|
5
|
+
|
|
6
|
+
def create
|
|
7
|
+
ticket_ids = params[:ticket_ids]
|
|
8
|
+
action = params[:action]
|
|
9
|
+
value = params[:value]
|
|
10
|
+
success_count = 0
|
|
11
|
+
|
|
12
|
+
tickets = Escalated::Ticket.where(id: ticket_ids)
|
|
13
|
+
|
|
14
|
+
tickets.each do |ticket|
|
|
15
|
+
begin
|
|
16
|
+
case action.to_s
|
|
17
|
+
when "status"
|
|
18
|
+
Services::TicketService.transition_status(ticket, value, actor: escalated_current_user)
|
|
19
|
+
when "priority"
|
|
20
|
+
Services::TicketService.change_priority(ticket, value, actor: escalated_current_user)
|
|
21
|
+
when "assign"
|
|
22
|
+
agent = Escalated.configuration.user_model.find(value)
|
|
23
|
+
Services::AssignmentService.assign(ticket, agent, actor: escalated_current_user)
|
|
24
|
+
when "tag"
|
|
25
|
+
Services::TicketService.add_tags(ticket, Array(value), actor: escalated_current_user)
|
|
26
|
+
when "close"
|
|
27
|
+
Services::TicketService.close(ticket, actor: escalated_current_user)
|
|
28
|
+
when "delete"
|
|
29
|
+
ticket.destroy!
|
|
30
|
+
end
|
|
31
|
+
success_count += 1
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
Rails.logger.warn("[Escalated::BulkActions] Failed to #{action} ticket ##{ticket.id}: #{e.message}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
redirect_back fallback_location: escalated.agent_tickets_path,
|
|
38
|
+
notice: "#{success_count} ticket(s) updated."
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Agent
|
|
3
|
+
class DashboardController < Escalated::ApplicationController
|
|
4
|
+
before_action :require_agent!
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
my_tickets = Escalated::Ticket.assigned_to(escalated_current_user.id)
|
|
8
|
+
all_open = Escalated::Ticket.by_open
|
|
9
|
+
|
|
10
|
+
stats = {
|
|
11
|
+
my_open: my_tickets.by_open.count,
|
|
12
|
+
my_waiting: my_tickets.where(status: :waiting_on_customer).count,
|
|
13
|
+
unassigned: all_open.unassigned.count,
|
|
14
|
+
total_open: all_open.count,
|
|
15
|
+
breached_sla: all_open.breached_sla.count,
|
|
16
|
+
resolved_today: Escalated::Ticket.where(
|
|
17
|
+
status: :resolved,
|
|
18
|
+
resolved_at: Time.current.beginning_of_day..Time.current.end_of_day
|
|
19
|
+
).count,
|
|
20
|
+
avg_first_response: calculate_avg_first_response,
|
|
21
|
+
avg_resolution_time: calculate_avg_resolution_time,
|
|
22
|
+
avg_csat_rating: calculate_avg_csat_rating,
|
|
23
|
+
total_ratings: Escalated::SatisfactionRating.count,
|
|
24
|
+
resolved_with_rating_count: Escalated::SatisfactionRating
|
|
25
|
+
.joins(:ticket)
|
|
26
|
+
.where("#{Escalated.table_name('tickets')}.status" => [:resolved, :closed])
|
|
27
|
+
.count
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
recent_tickets = my_tickets.by_open.recent.limit(10)
|
|
31
|
+
unassigned_tickets = all_open.unassigned.recent.limit(10)
|
|
32
|
+
breached_tickets = all_open.breached_sla.recent.limit(10)
|
|
33
|
+
|
|
34
|
+
render inertia: "Escalated/Agent/Dashboard", props: {
|
|
35
|
+
stats: stats,
|
|
36
|
+
recent_tickets: recent_tickets.map { |t| ticket_summary_json(t) },
|
|
37
|
+
unassigned_tickets: unassigned_tickets.map { |t| ticket_summary_json(t) },
|
|
38
|
+
breached_tickets: breached_tickets.map { |t| ticket_summary_json(t) },
|
|
39
|
+
sla_stats: Services::SlaService.stats
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def calculate_avg_first_response
|
|
46
|
+
tickets = Escalated::Ticket
|
|
47
|
+
.where.not(first_response_at: nil)
|
|
48
|
+
.where(created_at: 30.days.ago..Time.current)
|
|
49
|
+
|
|
50
|
+
return 0 if tickets.empty?
|
|
51
|
+
|
|
52
|
+
total_seconds = tickets.sum { |t| (t.first_response_at - t.created_at).to_f }
|
|
53
|
+
avg_seconds = total_seconds / tickets.count
|
|
54
|
+
(avg_seconds / 3600.0).round(1) # Return in hours
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def calculate_avg_resolution_time
|
|
58
|
+
tickets = Escalated::Ticket
|
|
59
|
+
.where.not(resolved_at: nil)
|
|
60
|
+
.where(created_at: 30.days.ago..Time.current)
|
|
61
|
+
|
|
62
|
+
return 0 if tickets.empty?
|
|
63
|
+
|
|
64
|
+
total_seconds = tickets.sum { |t| (t.resolved_at - t.created_at).to_f }
|
|
65
|
+
avg_seconds = total_seconds / tickets.count
|
|
66
|
+
(avg_seconds / 3600.0).round(1) # Return in hours
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def calculate_avg_csat_rating
|
|
70
|
+
ratings = Escalated::SatisfactionRating.all
|
|
71
|
+
return 0.0 if ratings.empty?
|
|
72
|
+
|
|
73
|
+
(ratings.average(:rating).to_f).round(2)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ticket_summary_json(ticket)
|
|
77
|
+
{
|
|
78
|
+
id: ticket.id,
|
|
79
|
+
reference: ticket.reference,
|
|
80
|
+
subject: ticket.subject,
|
|
81
|
+
status: ticket.status,
|
|
82
|
+
priority: ticket.priority,
|
|
83
|
+
requester_name: ticket.requester.respond_to?(:name) ? ticket.requester.name : ticket.requester&.email,
|
|
84
|
+
department: ticket.department&.name,
|
|
85
|
+
created_at: ticket.created_at&.iso8601,
|
|
86
|
+
updated_at: ticket.updated_at&.iso8601,
|
|
87
|
+
sla_breached: ticket.sla_breached,
|
|
88
|
+
sla_first_response_due_at: ticket.sla_first_response_due_at&.iso8601,
|
|
89
|
+
sla_resolution_due_at: ticket.sla_resolution_due_at&.iso8601
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|